0.34.3
This commit is contained in:
parent
0fda211e37
commit
31bafb0e55
49
HISTORY.md
49
HISTORY.md
|
|
@ -1,5 +1,51 @@
|
|||
# Bill Tracker — Changelog
|
||||
|
||||
## v0.34.3
|
||||
|
||||
### 🔧 Changed
|
||||
|
||||
- **Bump** — `0.34.2.1` → `0.34.3`
|
||||
|
||||
- **TrackerPage refactored into focused components** — `TrackerPage.jsx` was a 2 386-line monolith containing ~13 co-located sub-components. Each has been extracted to its own file in `client/components/tracker/`:
|
||||
- `FilterChip`, `StatusBadge`, `SummaryCards` (TrendIndicator / SummaryCard / TrendCard) — visual primitives
|
||||
- `EditableCell`, `PaymentProgress`, `LowerThisMonthButton`, `PaymentLedgerDialog`, `NotesCell` — payment sub-components
|
||||
- `AutopaySuggestionActions`, `TrackerRow`, `MobileTrackerRow` — bill row (desktop and mobile)
|
||||
- `TrackerBucket` — bucket container
|
||||
- Helper functions and constants (`rowEffectiveStatus`, `paymentSummary`, `ROW_STATUS_CLS`, etc.) extracted to `client/lib/trackerUtils.js`
|
||||
- `TrackerPage.jsx` is now **477 lines** (page layout + routing only)
|
||||
|
||||
- **TrackerPage filter/nav state is now URL-first** — `year`, `month`, `search`, and all 8 filter flags (`autopay`, `firstBucket`, `fifteenthBucket`, `unpaid`, `overdue`, `debt`, `category`, `cycle`) are stored in URL search params instead of local React state. Views are now bookmarkable, shareable, and survive back/forward navigation. Example: `/tracker?year=2026&month=5&ov=1&un=1`. The `search` param was previously partially URL-backed; this completes the pattern.
|
||||
|
||||
---
|
||||
|
||||
## v0.34.2.1
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
- **Overview quick-add bill** — The Monthly Overview header now includes a plus-button shortcut that opens the existing Add Bill modal and refreshes the tracker after saving.
|
||||
|
||||
### 🔧 Changed
|
||||
|
||||
- **Bump** — `0.34.2` → `0.34.2.1`
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **Async error handling hardened** — Five route handlers that called `bcrypt.compare()` or `hashPassword()` without a surrounding try/catch now return a clean 500 instead of leaving the promise rejection unhandled. Affected routes: `POST /api/auth/change-password`, `POST /api/admin/users` (auth router), `POST /api/admin/users` (admin router), `PUT /api/admin/users/:id/password`, `POST /api/profile/change-password`. A `process.on('unhandledRejection')` safety-net logger was also added to `server.js`. All other routes cited in the original bug report already had try/catch and required no changes.
|
||||
|
||||
- **SMTP password encrypted at rest** — `notify_smtp_password` was stored as plaintext in the `settings` table, exposing credentials in database backups or direct file access. It is now encrypted with AES-256-GCM via the existing `encryptionService` (same mechanism as SimpleFIN tokens). The route encrypts on save, the notification service decrypts on read with a legacy plaintext fallback, and migration v0.77 encrypts any existing plaintext password at startup. The masked `••••••••` API response is unchanged.
|
||||
|
||||
- **User deletion now cleans up audit_log** — The `DELETE /api/admin/users/:id` route was not explicitly deleting rows from `audit_log` (which has no `ON DELETE CASCADE` foreign key to `users`). Deleting a user left their audit trail orphaned in the database, referencing a non-existent user id. The route now explicitly deletes `audit_log`, `import_sessions`, and `import_history` rows for the user before removing the user row; the remaining ~20 user-owned tables are handled by `ON DELETE CASCADE`.
|
||||
|
||||
- **Payment mutations scoped to owner** — The `PUT`, `DELETE`, and `POST /:id/restore` handlers in `routes/payments.js` performed their `UPDATE payments` and bill balance `SELECT` queries using only the payment id, without re-asserting ownership in the SQL itself. Ownership was already verified via a JOIN before each mutation (not exploitable in practice), but the SQL provided no independent protection. All three mutation statements now include `AND bill_id IN (SELECT id FROM bills WHERE user_id = ? AND deleted_at IS NULL)`, and the bills balance re-fetch in DELETE and restore now includes `AND user_id = ?`. The response `SELECT` at the end of PUT and restore was also updated to use the ownership JOIN consistent with `GET /api/payments/:id`.
|
||||
|
||||
- **JSON body limit made explicit** — `express.json()` in `server.js` now declares `{ limit: '100kb' }` explicitly rather than relying on Express's implicit default. No behaviour change; import routes continue to override this per-endpoint (2 MB – 10 MB).
|
||||
|
||||
- **Silent `.catch(() => {})` replaced with console logging** — Twelve instances of empty catch handlers across the client codebase swallowed errors with no logging or user feedback: `LoginPage.jsx` (authMode/session pre-checks), `useAuth.jsx` (authMode check), `SnowballPage.jsx` (plan history load), `SubscriptionsPage.jsx` (bills load on mount and after reorder), `BankSyncSection.jsx` (bills load for match picker), `ImportSpreadsheetSection.jsx` (bills and categories load for import controls), `TransactionMatchingSection.jsx` (categories load), `Layout.jsx` (SimpleFIN status badge), and `ReleaseNotesDialog.jsx` (acknowledge-version fire-and-forget). All are background ambient data loads whose fallback silent state is correct for the user experience; they now log `console.error('[ComponentName] context message', err)` so failures are visible to developers without surfacing disruptive toasts.
|
||||
|
||||
- **Monetary aggregation rounding hardened** — Floating-point rounding was already applied per-payment in `statusService`, `billsService`, and `payments.js`, but the aggregation layer was unprotected: `reduce()` sums and subtraction results in `trackerService`, `routes/summary.js`, and `routes/monthly-starting-amounts.js` were returned without rounding, allowing IEEE 754 artifacts (e.g. `12.10 - 0.20 = 12.100000000000001`) to leak into API responses. All computed monetary aggregates (`total_paid`, `total_expected`, `paid_toward_due`, `remaining`, `total_remaining`, `overdue`, `combined_amount`, `*_remaining`, `expense_total`, `result`, etc.) are now passed through `Math.round(x * 100) / 100` before being returned. `roundMoney` is also now exported from `statusService` so other modules can share the same implementation instead of re-implementing it.
|
||||
|
||||
---
|
||||
|
||||
## v0.34.2
|
||||
|
||||
### 🔧 Changed
|
||||
|
|
@ -8,6 +54,7 @@
|
|||
|
||||
- **Subscription badge on Tracker** — The "S" subscription badge now appears consistently on all bill rows in the Tracker page. The reorder-mode row variant was missing the badge even though it showed the "AP" autopay badge — both badges now render in all row contexts.
|
||||
|
||||
- **Data page workflow tabs** — Data now groups tools into Sync & Match, Import Data, and Export & History tabs, with remembered collapsible cards and a compact status strip.
|
||||
---
|
||||
|
||||
## v0.34.1.3
|
||||
|
|
@ -26,7 +73,7 @@
|
|||
|
||||
- **Summary bill ordering** — Summary expenses now use the persisted bill order and support tracker-style drag/up/down reordering, while hiding reorder controls from printed/PDF summaries.
|
||||
- **Unified bill schedule editing** — Edit Bill now uses one canonical "Billing Schedule" field instead of separate Billing Cycle and Cycle Type controls.
|
||||
- **Data page workflow tabs** — Data now groups tools into Sync & Match, Import Data, and Export & History tabs, with remembered collapsible cards and a compact status strip.
|
||||
|
||||
|
||||
### 🔧 Changed
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ export function ReleaseNotesDialog() {
|
|||
const handleClose = () => {
|
||||
setOpen(false);
|
||||
setHasNewVersion(false); // optimistic — don't wait for the server
|
||||
api.acknowledgeVersion().catch(() => {}); // backend stores the seen release version
|
||||
api.acknowledgeVersion().catch(err => console.error('[ReleaseNotesDialog] failed to acknowledge version', err)); // backend stores the seen release version
|
||||
const prev = document.activeElement;
|
||||
if (prev?.focus) setTimeout(() => prev.focus(), 0);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -344,7 +344,7 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} })
|
|||
// Load bills once when connections become available (for the match picker)
|
||||
useEffect(() => {
|
||||
if (connections.length > 0 && bills.length === 0) {
|
||||
api.allBills().then(b => setBills(Array.isArray(b) ? b : [])).catch(() => {});
|
||||
api.allBills().then(b => setBills(Array.isArray(b) ? b : [])).catch(err => console.error('[BankSyncSection] failed to load bills', err));
|
||||
}
|
||||
}, [connections, bills.length]);
|
||||
|
||||
|
|
|
|||
|
|
@ -885,8 +885,8 @@ export default function ImportSpreadsheetSection({ onHistoryRefresh, cardProps =
|
|||
|
||||
// Load bills/categories for the decision controls
|
||||
useEffect(() => {
|
||||
api.bills().then(setAllBills).catch(() => {});
|
||||
api.categories().then(setCategories).catch(() => {});
|
||||
api.bills().then(setAllBills).catch(err => console.error('[ImportSpreadsheetSection] failed to load bills', err));
|
||||
api.categories().then(setCategories).catch(err => console.error('[ImportSpreadsheetSection] failed to load categories', err));
|
||||
}, []);
|
||||
|
||||
const opt = (k, v) => setOptions(prev => ({ ...prev, [k]: v }));
|
||||
|
|
|
|||
|
|
@ -435,7 +435,7 @@ export default function TransactionMatchingSection({ refreshKey, simplefinConn,
|
|||
useEffect(() => { loadTransactions(); }, [filter, refreshKey]);
|
||||
useEffect(() => { loadSuggestions(); }, [refreshKey]);
|
||||
useEffect(() => {
|
||||
api.categories().then(data => setCategories(data || [])).catch(() => {});
|
||||
api.categories().then(data => setCategories(data || [])).catch(err => console.error('[TransactionMatchingSection] failed to load categories', err));
|
||||
}, []);
|
||||
|
||||
const openMatchDialog = (tx) => {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ function SimplefinBadge() {
|
|||
useEffect(() => {
|
||||
api.simplefinStatus()
|
||||
.then(d => setEnabled(!!d.enabled))
|
||||
.catch(() => {});
|
||||
.catch(err => console.error('[Layout] simplefinStatus check failed', err));
|
||||
}, []);
|
||||
|
||||
if (!enabled) return null;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,51 @@
|
|||
import { Clock, X, CheckCircle2, Loader2 } from 'lucide-react';
|
||||
import { cn, fmt, fmtDate } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export function AutopaySuggestionActions({ row, loading, onConfirm, onDismiss, compact = false }) {
|
||||
const suggestion = row.autopay_suggestion;
|
||||
if (!suggestion) return null;
|
||||
|
||||
const title = `${fmt(suggestion.amount)} due ${fmtDate(suggestion.paid_date)}`;
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
'flex items-center gap-1.5',
|
||||
compact ? 'w-full flex-wrap justify-end sm:w-auto' : 'justify-end',
|
||||
)}>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex min-w-0 items-center gap-1.5 rounded-md border border-sky-500/20 bg-sky-500/10',
|
||||
'px-2 py-1 text-xs font-medium text-sky-600 dark:text-sky-300',
|
||||
compact ? 'mr-auto' : 'max-w-32',
|
||||
)}
|
||||
title={`Autopay suggested: ${title}`}
|
||||
>
|
||||
<Clock className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">{compact ? `Suggested ${fmt(suggestion.amount)}` : 'Suggested'}</span>
|
||||
</span>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
disabled={loading}
|
||||
onClick={onDismiss}
|
||||
className="h-8 w-8 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
|
||||
title={`Dismiss suggested autopay payment for ${title}`}
|
||||
aria-label={`Dismiss suggested autopay payment for ${row.name}`}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="default"
|
||||
disabled={loading}
|
||||
onClick={onConfirm}
|
||||
className="h-8 w-8"
|
||||
title={`Confirm suggested autopay payment for ${title}`}
|
||||
aria-label={`Confirm suggested autopay payment for ${row.name}`}
|
||||
>
|
||||
{loading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <CheckCircle2 className="h-3.5 w-3.5" />}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
import { useState, useRef } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/api.js';
|
||||
import { cn, fmt, fmtDate } from '@/lib/utils';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
// `threshold` = actual_amount ?? expected_amount for this bill/month
|
||||
export function EditableCell({ row, field, threshold, defaultPaymentDate, refresh }) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [value, setValue] = useState('');
|
||||
const inputRef = useRef(null);
|
||||
|
||||
const displayVal = field === 'amount'
|
||||
? (row.total_paid > 0 ? fmt(row.total_paid) : '—')
|
||||
: (row.last_paid_date ? fmtDate(row.last_paid_date) : '—');
|
||||
|
||||
const isEmpty = field === 'amount' ? row.total_paid <= 0 : !row.last_paid_date;
|
||||
// Mismatch when paid amount differs from the effective threshold for this month
|
||||
const mismatch = field === 'amount' && row.total_paid > 0 && row.total_paid !== threshold;
|
||||
|
||||
function startEdit() {
|
||||
if (editing) return;
|
||||
setValue(field === 'amount'
|
||||
? (row.total_paid > 0 ? String(row.total_paid) : '')
|
||||
: (row.last_paid_date || ''));
|
||||
setEditing(true);
|
||||
setTimeout(() => { inputRef.current?.focus(); inputRef.current?.select(); }, 0);
|
||||
}
|
||||
|
||||
async function commit() {
|
||||
setEditing(false);
|
||||
const val = value.trim();
|
||||
if (!val) return;
|
||||
try {
|
||||
if (row.payments && row.payments.length > 0) {
|
||||
const update = {};
|
||||
if (field === 'amount') update.amount = parseFloat(val);
|
||||
if (field === 'date') update.paid_date = val;
|
||||
await api.updatePayment(row.payments[0].id, update);
|
||||
} else {
|
||||
await api.createPayment({
|
||||
bill_id: row.id,
|
||||
amount: field === 'amount' ? parseFloat(val) : threshold,
|
||||
paid_date: field === 'date' ? val : defaultPaymentDate,
|
||||
});
|
||||
}
|
||||
toast.success('Saved');
|
||||
refresh();
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function onKeyDown(e) {
|
||||
if (e.key === 'Enter') inputRef.current?.blur();
|
||||
if (e.key === 'Escape') { setValue(''); setEditing(false); }
|
||||
}
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<Input
|
||||
ref={inputRef}
|
||||
type={field === 'date' ? 'date' : 'number'}
|
||||
step={field === 'amount' ? '0.01' : undefined}
|
||||
min={field === 'amount' ? '0' : undefined}
|
||||
value={value}
|
||||
onChange={e => setValue(e.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={onKeyDown}
|
||||
className="h-7 w-28 text-right font-mono text-sm bg-background/80 border-border/60"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
onClick={startEdit}
|
||||
title={`Click to edit ${field === 'amount' ? 'payment amount' : 'paid date'}`}
|
||||
className={cn(
|
||||
'cursor-pointer rounded-md px-1.5 py-0.5 text-sm font-mono',
|
||||
'transition-all duration-150 hover:bg-accent hover:ring-1 hover:ring-border',
|
||||
isEmpty && 'text-muted-foreground',
|
||||
mismatch && 'text-amber-500',
|
||||
!isEmpty && !mismatch && 'text-emerald-500',
|
||||
)}
|
||||
>
|
||||
{displayVal}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function FilterChip({ active, children, onClick }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'h-8 rounded-full border px-3 text-xs font-medium transition-colors',
|
||||
active
|
||||
? 'border-primary/50 bg-primary/15 text-primary'
|
||||
: 'border-border/70 bg-card/70 text-muted-foreground hover:bg-accent hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/api.js';
|
||||
import { cn, fmt } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { MONTHS, rowThreshold, paymentSummary } from '@/lib/trackerUtils';
|
||||
|
||||
export function LowerThisMonthButton({ row, year, month, refresh, compact = false }) {
|
||||
const threshold = rowThreshold(row);
|
||||
const summary = paymentSummary(row, threshold);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
if (row.is_skipped || !summary.partial) return null;
|
||||
|
||||
async function handleClick() {
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.saveBillMonthlyState(row.id, {
|
||||
year,
|
||||
month,
|
||||
actual_amount: summary.paid,
|
||||
notes: row.monthly_notes || null,
|
||||
is_skipped: row.is_skipped,
|
||||
});
|
||||
toast.success(`${MONTHS[month - 1]} amount set to ${fmt(summary.paid)}`);
|
||||
refresh?.();
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Failed to update monthly amount');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={saving}
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
'h-7 px-2.5 text-xs font-semibold text-amber-600 hover:bg-amber-500/10 hover:text-amber-700',
|
||||
'dark:text-amber-400 dark:hover:text-amber-300',
|
||||
compact && 'h-8',
|
||||
)}
|
||||
title={`Set ${MONTHS[month - 1]} amount to ${fmt(summary.paid)} because this bill was lower this month`}
|
||||
>
|
||||
{saving ? 'Saving...' : 'Bill was lower'}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,376 @@
|
|||
import { useState, useRef } from 'react';
|
||||
import { ArrowDown, ArrowUp, GripVertical, Pencil } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/api.js';
|
||||
import { cn, fmt, fmtDate } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle,
|
||||
AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import MonthlyStateDialog from '@/components/tracker/MonthlyStateDialog';
|
||||
import PaymentModal from '@/components/tracker/PaymentModal';
|
||||
import { paymentDateForTrackerMonth, paymentSummary, ROW_STATUS_CLS } from '@/lib/trackerUtils';
|
||||
import { StatusBadge } from '@/components/tracker/StatusBadge';
|
||||
import { PaymentProgress } from '@/components/tracker/PaymentProgress';
|
||||
import { LowerThisMonthButton } from '@/components/tracker/LowerThisMonthButton';
|
||||
import { PaymentLedgerDialog } from '@/components/tracker/PaymentLedgerDialog';
|
||||
import { NotesCell } from '@/components/tracker/NotesCell';
|
||||
import { AutopaySuggestionActions } from '@/components/tracker/AutopaySuggestionActions';
|
||||
|
||||
export function MobileTrackerRow({ row, year, month, refresh, index, onEditBill, moveControls, dragProps }) {
|
||||
const amountRef = useRef(null);
|
||||
const [editPayment, setEditPayment] = useState(null);
|
||||
const [paymentLedgerOpen, setPaymentLedgerOpen] = useState(false);
|
||||
const [showMbs, setShowMbs] = useState(false);
|
||||
const [confirmUnpay, setConfirmUnpay] = useState(false);
|
||||
const [suggestionLoading, setSuggestionLoading] = useState(false);
|
||||
|
||||
const threshold = row.actual_amount != null ? row.actual_amount : row.expected_amount;
|
||||
const defaultPaymentDate = paymentDateForTrackerMonth(year, month, row.due_day);
|
||||
const isSkipped = !!row.is_skipped;
|
||||
const hasAutopaySuggestion = !!row.autopay_suggestion && !isSkipped;
|
||||
const isPaidByThreshold = row.total_paid > 0 && row.total_paid >= threshold;
|
||||
const isPaid = row.status === 'paid' || (row.status === 'autodraft' && !hasAutopaySuggestion) || isPaidByThreshold;
|
||||
const effectiveStatus = isSkipped
|
||||
? 'skipped'
|
||||
: (isPaidByThreshold && row.status !== 'paid' && row.status !== 'autodraft')
|
||||
? 'paid'
|
||||
: row.status;
|
||||
const rowBg = isSkipped ? '' : (ROW_STATUS_CLS[effectiveStatus] || '');
|
||||
const remaining = Math.max((threshold || 0) - (row.total_paid || 0), 0);
|
||||
const summary = paymentSummary(row, threshold);
|
||||
|
||||
async function handleQuickPay() {
|
||||
const val = parseFloat(amountRef.current?.value);
|
||||
if (!val || val <= 0) { toast.error('Enter a payment amount'); return; }
|
||||
try {
|
||||
await api.quickPay({ bill_id: row.id, amount: val, paid_date: defaultPaymentDate });
|
||||
toast.success('Payment added');
|
||||
refresh();
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function performTogglePaid() {
|
||||
try {
|
||||
const result = await api.togglePaid(row.id, {
|
||||
amount: isPaid ? undefined : threshold,
|
||||
year: year,
|
||||
month: month,
|
||||
});
|
||||
if (isPaid && result.paymentId) {
|
||||
toast.success('Payment moved to recovery', {
|
||||
action: {
|
||||
label: 'Undo',
|
||||
onClick: async () => {
|
||||
try {
|
||||
await api.restorePayment(result.paymentId);
|
||||
toast.success('Payment restored');
|
||||
refresh();
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Failed to restore payment');
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
} else {
|
||||
toast.success('Payment recorded');
|
||||
}
|
||||
refresh();
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Failed to toggle payment status');
|
||||
}
|
||||
}
|
||||
|
||||
function handleTogglePaid() {
|
||||
if (isPaid) {
|
||||
setConfirmUnpay(true);
|
||||
return;
|
||||
}
|
||||
performTogglePaid();
|
||||
}
|
||||
|
||||
async function handleConfirmSuggestion() {
|
||||
setSuggestionLoading(true);
|
||||
try {
|
||||
const result = await api.confirmAutopaySuggestion(row.id, { year, month });
|
||||
toast.success(result.created ? 'Autopay payment confirmed' : 'Autopay already recorded');
|
||||
refresh();
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Failed to confirm autopay suggestion');
|
||||
} finally {
|
||||
setSuggestionLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDismissSuggestion() {
|
||||
setSuggestionLoading(true);
|
||||
try {
|
||||
await api.dismissAutopaySuggestion(row.id, { year, month });
|
||||
toast.success('Autopay suggestion dismissed');
|
||||
refresh();
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Failed to dismiss autopay suggestion');
|
||||
} finally {
|
||||
setSuggestionLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
draggable={dragProps?.draggable}
|
||||
onDragStart={dragProps?.onDragStart}
|
||||
onDragEnter={dragProps?.onDragEnter}
|
||||
onDragOver={dragProps?.onDragOver}
|
||||
onDragEnd={dragProps?.onDragEnd}
|
||||
onDrop={dragProps?.onDrop}
|
||||
className={cn(
|
||||
'rounded-lg border border-border/60 bg-background/60 p-3 shadow-sm',
|
||||
'space-y-3 transition-colors',
|
||||
isSkipped ? 'opacity-55' : rowBg,
|
||||
dragProps?.isDragging && 'opacity-45',
|
||||
dragProps?.isDropTarget && 'ring-2 ring-primary/40',
|
||||
)}
|
||||
style={{ animationDelay: `${index * 40}ms` }}
|
||||
>
|
||||
<div className="flex min-w-0 items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 gap-2">
|
||||
<div className="flex shrink-0 items-center gap-0.5 pt-0.5">
|
||||
<GripVertical
|
||||
className={cn(
|
||||
'h-4 w-4 text-muted-foreground/55',
|
||||
moveControls?.enabled && 'cursor-grab',
|
||||
!moveControls?.enabled && 'opacity-30',
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={moveControls?.onMoveUp}
|
||||
disabled={!moveControls?.enabled || !moveControls?.canMoveUp || moveControls?.moving}
|
||||
className="rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-25"
|
||||
title="Move bill up"
|
||||
aria-label={`Move ${row.name} up`}
|
||||
>
|
||||
<ArrowUp className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={moveControls?.onMoveDown}
|
||||
disabled={!moveControls?.enabled || !moveControls?.canMoveDown || moveControls?.moving}
|
||||
className="rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-25"
|
||||
title="Move bill down"
|
||||
aria-label={`Move ${row.name} down`}
|
||||
>
|
||||
<ArrowDown className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{row.website ? (
|
||||
<a
|
||||
href={row.website}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={cn(
|
||||
'min-w-0 truncate text-[15px] font-semibold leading-tight text-foreground',
|
||||
'underline-offset-2 transition-colors hover:text-primary hover:underline',
|
||||
isSkipped && 'line-through',
|
||||
)}
|
||||
>
|
||||
{row.name}
|
||||
</a>
|
||||
) : (
|
||||
<span className={cn('min-w-0 truncate text-[15px] font-semibold leading-tight text-foreground', isSkipped && 'line-through')}>
|
||||
{row.name}
|
||||
</span>
|
||||
)}
|
||||
{row.autopay_enabled && (
|
||||
<span
|
||||
className="inline-flex shrink-0 rounded border border-sky-500/25 bg-sky-500/10 px-1.5 py-0.5 text-[10px] font-bold leading-none text-sky-600 dark:text-sky-300"
|
||||
title="Autopay"
|
||||
>
|
||||
AP
|
||||
</span>
|
||||
)}
|
||||
{row.is_subscription && (
|
||||
<span
|
||||
className="inline-flex shrink-0 rounded border border-indigo-500/25 bg-indigo-500/10 px-1.5 py-0.5 text-[10px] font-bold leading-none text-indigo-600 dark:text-indigo-300"
|
||||
title="Subscription"
|
||||
>
|
||||
S
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
size="icon" variant="ghost"
|
||||
className="h-7 w-7 opacity-100 transition-opacity text-muted-foreground hover:text-foreground hover:bg-accent lg:opacity-0 lg:group-hover:opacity-100"
|
||||
title="Edit bill"
|
||||
onClick={() => onEditBill?.(row)}
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
{row.monthly_notes && (
|
||||
<p className="mt-1 line-clamp-2 text-xs italic text-amber-500/80" title={row.monthly_notes}>
|
||||
{row.monthly_notes}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge status={effectiveStatus} clickable={!isSkipped} onClick={handleTogglePaid} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-2 text-xs text-muted-foreground sm:grid-cols-4">
|
||||
<div>
|
||||
<p className="uppercase tracking-wide text-muted-foreground/60">Due</p>
|
||||
<p className="tracker-number mt-0.5 text-sm font-medium text-foreground/90">{fmtDate(row.due_date)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="uppercase tracking-wide text-muted-foreground/60">Category</p>
|
||||
<p className="mt-0.5 truncate text-sm text-foreground">{row.category_name || 'Uncategorized'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="uppercase tracking-wide text-muted-foreground/60">Expected</p>
|
||||
<p className={cn('tracker-number mt-0.5 text-sm font-semibold', row.actual_amount != null ? 'text-amber-300' : 'text-foreground')}>
|
||||
{fmt(threshold)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="uppercase tracking-wide text-muted-foreground/60">Last Month</p>
|
||||
<p className="tracker-number mt-0.5 text-sm font-medium text-muted-foreground/80">
|
||||
{fmt(row.previous_month_paid)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="uppercase tracking-wide text-muted-foreground/60">Remaining</p>
|
||||
<p className={cn('tracker-number mt-0.5 text-sm font-semibold', remaining > 0 ? 'text-foreground' : 'text-emerald-300')}>
|
||||
{fmt(remaining)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-border/50 bg-muted/20">
|
||||
<PaymentProgress row={row} threshold={threshold} onOpen={() => setPaymentLedgerOpen(true)} compact />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="grid grid-cols-2 gap-2 text-xs sm:flex sm:items-center">
|
||||
<div className="rounded-md bg-muted/45 px-2 py-1.5">
|
||||
<span className="text-muted-foreground">Paid </span>
|
||||
<span className="tracker-number font-semibold text-emerald-300">{row.total_paid > 0 ? fmt(row.total_paid) : '—'}</span>
|
||||
</div>
|
||||
<div className="rounded-md bg-muted/45 px-2 py-1.5">
|
||||
<span className="text-muted-foreground">Date </span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPaymentLedgerOpen(true)}
|
||||
className="tracker-number rounded font-medium text-foreground underline-offset-2 hover:underline"
|
||||
>
|
||||
{row.last_paid_date ? fmtDate(row.last_paid_date) : '—'}
|
||||
{summary.count > 1 && <span className="ml-1 text-[10px] text-muted-foreground">({summary.count})</span>}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-end gap-1.5">
|
||||
{hasAutopaySuggestion && (
|
||||
<AutopaySuggestionActions
|
||||
row={row}
|
||||
loading={suggestionLoading}
|
||||
onConfirm={handleConfirmSuggestion}
|
||||
onDismiss={handleDismissSuggestion}
|
||||
compact
|
||||
/>
|
||||
)}
|
||||
{!isPaid && !isSkipped && !hasAutopaySuggestion && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Input
|
||||
ref={amountRef}
|
||||
type="number" min="0" step="0.01"
|
||||
defaultValue={summary.remaining || threshold}
|
||||
className="tracker-number h-8 w-24 text-right text-sm font-medium bg-background/70 border-border/60"
|
||||
title="Payment amount"
|
||||
aria-label={`${row.name} payment amount`}
|
||||
/>
|
||||
<Button
|
||||
size="sm" variant="default"
|
||||
onClick={handleQuickPay}
|
||||
className="h-8 px-3 text-xs font-semibold"
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<LowerThisMonthButton
|
||||
row={row}
|
||||
year={year}
|
||||
month={month}
|
||||
refresh={refresh}
|
||||
compact
|
||||
/>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-border/50 bg-muted/25 px-2 py-1.5">
|
||||
<NotesCell row={{ ...row, year, month }} refresh={refresh} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editPayment && (
|
||||
<PaymentModal
|
||||
payment={editPayment}
|
||||
onClose={() => setEditPayment(null)}
|
||||
onSave={refresh}
|
||||
/>
|
||||
)}
|
||||
|
||||
{paymentLedgerOpen && (
|
||||
<PaymentLedgerDialog
|
||||
row={row}
|
||||
year={year}
|
||||
month={month}
|
||||
threshold={threshold}
|
||||
defaultPaymentDate={defaultPaymentDate}
|
||||
onClose={() => setPaymentLedgerOpen(false)}
|
||||
onSaved={refresh}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showMbs && (
|
||||
<MonthlyStateDialog
|
||||
row={row}
|
||||
year={year}
|
||||
month={month}
|
||||
open={showMbs}
|
||||
onOpenChange={setShowMbs}
|
||||
onSaved={refresh}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AlertDialog open={confirmUnpay} onOpenChange={setConfirmUnpay}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Mark this bill unpaid?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This removes the current payment record for this month and moves it into recovery.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={performTogglePaid}
|
||||
>
|
||||
Remove Payment
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/api.js';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// Monthly notes stored in monthly_bill_state — per-month, not per-bill.
|
||||
export function NotesCell({ row, refresh }) {
|
||||
const savedNote = row.monthly_notes || '';
|
||||
const [value, setValue] = useState(savedNote);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
async function handleBlur() {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === savedNote) return;
|
||||
|
||||
const year = row.year;
|
||||
const month = row.month;
|
||||
|
||||
if (!year || !month) {
|
||||
toast.error('Cannot save notes without year/month context');
|
||||
setValue(savedNote);
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.saveBillMonthlyState(row.id, {
|
||||
year,
|
||||
month,
|
||||
notes: trimmed || null,
|
||||
is_skipped: row.is_skipped,
|
||||
actual_amount: row.actual_amount,
|
||||
});
|
||||
refresh();
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
setValue(savedNote);
|
||||
} finally { setSaving(false); }
|
||||
}
|
||||
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={e => setValue(e.target.value)}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={e => { if (e.key === 'Enter') e.currentTarget.blur(); }}
|
||||
placeholder='Add monthly notes…'
|
||||
disabled={saving}
|
||||
className={cn(
|
||||
'w-full bg-transparent text-sm placeholder:text-muted-foreground/40',
|
||||
'border-0 outline-none ring-0',
|
||||
'text-muted-foreground focus:text-foreground',
|
||||
'transition-colors duration-150',
|
||||
'disabled:cursor-not-allowed disabled:opacity-40',
|
||||
value && 'text-foreground/80',
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,185 @@
|
|||
import { useState } from 'react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/api.js';
|
||||
import { fmt, fmtDate } from '@/lib/utils';
|
||||
import { METHOD_NONE, paymentSummary } from '@/lib/trackerUtils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||
} from '@/components/ui/select';
|
||||
import PaymentModal from '@/components/tracker/PaymentModal';
|
||||
import { PaymentProgress } from '@/components/tracker/PaymentProgress';
|
||||
import { LowerThisMonthButton } from '@/components/tracker/LowerThisMonthButton';
|
||||
|
||||
export function PaymentLedgerDialog({ row, year, month, threshold, defaultPaymentDate, onClose, onSaved }) {
|
||||
const summary = paymentSummary(row, threshold);
|
||||
const [amount, setAmount] = useState(String(summary.remaining || summary.target || ''));
|
||||
const [date, setDate] = useState(defaultPaymentDate);
|
||||
const [method, setMethod] = useState(METHOD_NONE);
|
||||
const [notes, setNotes] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [editPayment, setEditPayment] = useState(null);
|
||||
const payments = [...(row.payments || [])].sort((a, b) => String(b.paid_date).localeCompare(String(a.paid_date)));
|
||||
|
||||
async function handleAdd(e) {
|
||||
e.preventDefault();
|
||||
const parsedAmount = parseFloat(amount);
|
||||
if (!Number.isFinite(parsedAmount) || parsedAmount <= 0) {
|
||||
toast.error('Enter a positive payment amount');
|
||||
return;
|
||||
}
|
||||
if (!date) {
|
||||
toast.error('Choose a payment date');
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.createPayment({
|
||||
bill_id: row.id,
|
||||
amount: parsedAmount,
|
||||
paid_date: date,
|
||||
method: method === METHOD_NONE ? null : method,
|
||||
notes: notes || null,
|
||||
});
|
||||
toast.success('Partial payment added');
|
||||
onSaved?.();
|
||||
onClose?.();
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Failed to add payment');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open onOpenChange={value => { if (!value) onClose(); }}>
|
||||
<DialogContent className="max-h-[92svh] overflow-y-auto border-border/60 bg-card/95 backdrop-blur-xl sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base font-semibold tracking-tight">{row.name} Payments</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border border-border/60 bg-muted/25 p-3">
|
||||
<PaymentProgress row={row} threshold={threshold} onOpen={() => {}} />
|
||||
<div className="mt-2 flex justify-end">
|
||||
<LowerThisMonthButton
|
||||
row={row}
|
||||
year={year}
|
||||
month={month}
|
||||
refresh={onSaved}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-[1fr_1fr]">
|
||||
<div className="rounded-lg border border-border/60 bg-background/45 p-3">
|
||||
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">Payment History</p>
|
||||
{payments.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{payments.map(payment => (
|
||||
<div key={payment.id} className="flex items-center justify-between gap-3 rounded-md border border-border/50 bg-card/60 px-3 py-2">
|
||||
<div className="min-w-0">
|
||||
<p className="font-mono text-sm font-semibold text-foreground">{fmt(payment.amount)}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{fmtDate(payment.paid_date)}
|
||||
{payment.method ? ` · ${payment.method}` : ''}
|
||||
</p>
|
||||
{payment.notes && (
|
||||
<p className="mt-0.5 truncate text-xs text-muted-foreground/80">{payment.notes}</p>
|
||||
)}
|
||||
</div>
|
||||
<Button type="button" size="sm" variant="ghost" className="h-8 px-2.5 text-xs" onClick={() => setEditPayment(payment)}>
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="rounded-md border border-dashed border-border/70 px-3 py-8 text-center text-sm text-muted-foreground">
|
||||
No payments recorded for this month.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleAdd} className="rounded-lg border border-border/60 bg-background/45 p-3">
|
||||
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">Add Partial Payment</p>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`partial-amount-${row.id}`} className="text-xs uppercase tracking-wider text-muted-foreground">Amount</Label>
|
||||
<Input
|
||||
id={`partial-amount-${row.id}`}
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={amount}
|
||||
onChange={e => setAmount(e.target.value)}
|
||||
className="font-mono bg-background/70 border-border/60"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`partial-date-${row.id}`} className="text-xs uppercase tracking-wider text-muted-foreground">Paid Date</Label>
|
||||
<Input
|
||||
id={`partial-date-${row.id}`}
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={e => setDate(e.target.value)}
|
||||
className="font-mono bg-background/70 border-border/60"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Method</Label>
|
||||
<Select value={method} onValueChange={setMethod}>
|
||||
<SelectTrigger className="bg-background/70 border-border/60">
|
||||
<SelectValue placeholder="—" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={METHOD_NONE}>—</SelectItem>
|
||||
<SelectItem value="bank">Bank Transfer</SelectItem>
|
||||
<SelectItem value="card">Card</SelectItem>
|
||||
<SelectItem value="autopay">Autopay</SelectItem>
|
||||
<SelectItem value="check">Check</SelectItem>
|
||||
<SelectItem value="cash">Cash</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`partial-notes-${row.id}`} className="text-xs uppercase tracking-wider text-muted-foreground">Notes</Label>
|
||||
<Input
|
||||
id={`partial-notes-${row.id}`}
|
||||
value={notes}
|
||||
onChange={e => setNotes(e.target.value)}
|
||||
className="bg-background/70 border-border/60"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={busy} className="w-full gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
{busy ? 'Adding...' : 'Add Payment'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{editPayment && (
|
||||
<PaymentModal
|
||||
payment={editPayment}
|
||||
onClose={() => setEditPayment(null)}
|
||||
onSave={() => {
|
||||
onSaved?.();
|
||||
setEditPayment(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
import { cn, fmt } from '@/lib/utils';
|
||||
import { paymentSummary } from '@/lib/trackerUtils';
|
||||
|
||||
export function PaymentProgress({ row, threshold, onOpen, onMarkFullAmount, compact = false }) {
|
||||
const summary = paymentSummary(row, threshold);
|
||||
const barTone = summary.remaining === 0
|
||||
? 'bg-emerald-500'
|
||||
: summary.paid > 0
|
||||
? 'bg-amber-500'
|
||||
: 'bg-muted-foreground/40';
|
||||
|
||||
const amountLabel = (() => {
|
||||
if (summary.paid === 0) return '—';
|
||||
if (summary.overpaid > 0) return `${fmt(summary.paidTowardDue)} · overpaid`;
|
||||
if (summary.remaining > 0) return `${fmt(summary.paidTowardDue)} paid`;
|
||||
return fmt(summary.paidTowardDue);
|
||||
})();
|
||||
|
||||
const showQuickFix = onMarkFullAmount && summary.partial && summary.paid > 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
className={cn(
|
||||
'w-full rounded-md text-left transition-colors hover:bg-accent/60 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50',
|
||||
compact ? 'p-2' : 'px-2 py-1.5',
|
||||
)}
|
||||
title="View payment history"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 text-xs">
|
||||
<span className={cn('tracker-number text-[13px] font-semibold', summary.paid > 0 ? 'text-emerald-300' : 'text-muted-foreground/85')}>
|
||||
{amountLabel}
|
||||
</span>
|
||||
{summary.count > 1 && (
|
||||
<span className="shrink-0 rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||
{summary.count}×
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1.5 h-1.5 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className={cn('h-full rounded-full transition-all', barTone)}
|
||||
style={{ width: `${summary.percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
{showQuickFix && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onMarkFullAmount}
|
||||
className="mt-0.5 w-full rounded px-2 py-0.5 text-left text-[10px] text-muted-foreground/60 transition-colors hover:bg-accent/50 hover:text-foreground"
|
||||
title={`Set ${fmt(summary.paidTowardDue)} as the full amount due this month`}
|
||||
>
|
||||
✓ {fmt(summary.paidTowardDue)} is the full amount
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
import React, { useMemo } from 'react';
|
||||
import { Loader2, AlertCircle } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { STATUS_META } from '@/lib/trackerUtils';
|
||||
|
||||
export const StatusBadge = React.memo(function StatusBadge({ status, clickable, onClick, loading }) {
|
||||
const meta = useMemo(() => STATUS_META[status] || STATUS_META.upcoming, [status]);
|
||||
|
||||
const isSkipped = status === 'skipped';
|
||||
const isUrgent = status === 'late' || status === 'missed';
|
||||
const canClick = clickable && !isSkipped && !loading;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canClick || loading}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'inline-flex items-center px-2.5 py-0.5 text-[11px] rounded-md font-semibold',
|
||||
'uppercase tracking-wide whitespace-nowrap',
|
||||
'transition-all duration-150',
|
||||
isUrgent && 'gap-1.5 px-2.5 py-1 text-xs',
|
||||
canClick && 'cursor-pointer hover:scale-105 hover:shadow-sm',
|
||||
canClick && status === 'paid' && 'hover:bg-red-500/20 hover:text-red-600 hover:border-red-500/40',
|
||||
canClick && status !== 'paid' && 'hover:bg-emerald-500/20 hover:text-emerald-600 hover:border-emerald-500/40',
|
||||
loading && 'opacity-60 cursor-wait',
|
||||
meta.cls,
|
||||
)}
|
||||
title={canClick ? (status === 'paid' || status === 'autodraft' ? 'Click to mark unpaid' : 'Click to mark paid') : undefined}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
|
||||
{meta.label}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{isUrgent && <AlertCircle className="h-3.5 w-3.5" />}
|
||||
{meta.label}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
import { TrendingUp, AlertCircle, Clock, CheckCircle2, Settings2 } from 'lucide-react';
|
||||
import { cn, fmt } from '@/lib/utils';
|
||||
|
||||
const CARD_DEFS = {
|
||||
starting: {
|
||||
label: 'Starting',
|
||||
icon: TrendingUp,
|
||||
bar: 'from-slate-400 to-slate-300',
|
||||
glow: '',
|
||||
valueClass: 'text-foreground',
|
||||
activateWhen: () => true,
|
||||
},
|
||||
paid: {
|
||||
label: 'Total Paid',
|
||||
icon: CheckCircle2,
|
||||
bar: 'from-emerald-500 to-emerald-300',
|
||||
glow: 'shadow-[0_4px_20px_rgba(16,185,129,0.15)]',
|
||||
borderActive: 'border-emerald-400/40',
|
||||
valueClass: 'text-emerald-600 dark:text-emerald-200',
|
||||
activateWhen: (v) => v > 0,
|
||||
},
|
||||
remaining: {
|
||||
label: 'Remaining',
|
||||
icon: Clock,
|
||||
bar: 'from-blue-400 to-indigo-300',
|
||||
glow: '',
|
||||
valueClass: 'text-foreground',
|
||||
activateWhen: () => true,
|
||||
},
|
||||
overdue: {
|
||||
label: 'Overdue',
|
||||
icon: AlertCircle,
|
||||
bar: 'from-rose-400 to-orange-300',
|
||||
glow: 'shadow-[0_4px_20px_rgba(251,113,133,0.10)]',
|
||||
borderActive: 'border-rose-400/35',
|
||||
valueClass: 'text-red-500 dark:text-rose-200',
|
||||
activateWhen: (v) => v > 0,
|
||||
},
|
||||
};
|
||||
|
||||
export function TrendIndicator({ trend }) {
|
||||
if (!trend) return null;
|
||||
|
||||
const { direction, percent_change } = trend;
|
||||
|
||||
let icon, color, text;
|
||||
switch (direction) {
|
||||
case 'up':
|
||||
icon = '↑';
|
||||
color = 'text-emerald-500';
|
||||
text = `${icon} ${percent_change}%`;
|
||||
break;
|
||||
case 'down':
|
||||
icon = '↓';
|
||||
color = 'text-red-500';
|
||||
text = `${icon} ${Math.abs(percent_change)}%`;
|
||||
break;
|
||||
default:
|
||||
icon = '→';
|
||||
color = 'text-muted-foreground';
|
||||
text = `${icon} ${percent_change}%`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`text-lg font-bold ${color}`}>
|
||||
{text}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground whitespace-nowrap">
|
||||
vs 3-mo avg
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SummaryCard({ type, value, onEdit, hint, label }) {
|
||||
const def = CARD_DEFS[type];
|
||||
const isActive = def.activateWhen(value || 0);
|
||||
const Icon = def.icon;
|
||||
const displayLabel = label || def.label;
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
'flex-1 min-w-0 relative overflow-hidden rounded-xl border border-border/80',
|
||||
'bg-card/95 px-5 py-4 shadow-sm shadow-black/15 transition-all duration-300',
|
||||
isActive && def.glow,
|
||||
isActive && def.borderActive,
|
||||
)}>
|
||||
<div className={cn(
|
||||
'absolute top-0 left-0 right-0 h-[3px] bg-gradient-to-r',
|
||||
def.bar,
|
||||
!isActive && (type === 'paid' || type === 'overdue') && 'opacity-30',
|
||||
)} />
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Icon className={cn('h-4 w-4', isActive ? def.valueClass : 'text-muted-foreground')} />
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{displayLabel}
|
||||
</p>
|
||||
{type === 'starting' && onEdit && (
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="ml-auto h-4 w-4 text-muted-foreground hover:text-foreground transition-colors"
|
||||
title="Edit monthly starting amounts"
|
||||
aria-label="Edit monthly starting amounts"
|
||||
>
|
||||
<Settings2 className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className={cn(
|
||||
'text-[1.75rem] font-bold tracking-tight font-mono leading-none',
|
||||
isActive ? def.valueClass : 'text-foreground',
|
||||
)}>
|
||||
{fmt(value)}
|
||||
</p>
|
||||
{hint && <p className="mt-2 text-[11px] text-muted-foreground">{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TrendCard({ trend }) {
|
||||
if (!trend) return null;
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-w-0 relative overflow-hidden rounded-xl border border-border/80 bg-card/95 px-5 py-4 shadow-sm shadow-black/15 transition-all duration-300">
|
||||
<div className="absolute top-0 left-0 right-0 h-[3px] bg-gradient-to-r from-purple-500 to-indigo-400" />
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<TrendingUp className="h-4 w-4 text-foreground" />
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
3-Month Trend
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-center h-10">
|
||||
<TrendIndicator trend={trend} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,248 @@
|
|||
import { useState } from 'react';
|
||||
import { cn, fmt } from '@/lib/utils';
|
||||
import {
|
||||
Table, TableHeader, TableBody, TableHead, TableRow, TableCell,
|
||||
} from '@/components/ui/table';
|
||||
import { moveInArray } from '@/lib/trackerUtils';
|
||||
import { TrackerRow as Row } from '@/components/tracker/TrackerRow';
|
||||
import { MobileTrackerRow } from '@/components/tracker/MobileTrackerRow';
|
||||
|
||||
export function TrackerBucket({ label, rows, year, month, refresh, onEditBill, loading, onReorderRows, reorderEnabled, movingBillId }) {
|
||||
const [draggingId, setDraggingId] = useState(null);
|
||||
const [dropTargetId, setDropTargetId] = useState(null);
|
||||
// Use actual_amount (if set) as the per-row threshold; exclude skipped rows from totals
|
||||
const activeRows = rows.filter(r => !r.is_skipped);
|
||||
const totalThreshold = activeRows.reduce((s, r) => s + (r.actual_amount ?? r.expected_amount ?? 0), 0);
|
||||
const totalPaid = activeRows.reduce((s, r) => s + (r.total_paid || 0), 0);
|
||||
const totalPaidTowardDue = activeRows.reduce((s, r) => {
|
||||
const threshold = Number(r.actual_amount ?? r.expected_amount ?? 0) || 0;
|
||||
const cappedPaid = Number(r.paid_toward_due);
|
||||
return s + (Number.isFinite(cappedPaid) ? cappedPaid : Math.min(Number(r.total_paid) || 0, threshold));
|
||||
}, 0);
|
||||
const totalOverpaid = Math.max(totalPaid - totalPaidTowardDue, 0);
|
||||
const totalRemaining = Math.max(totalThreshold - totalPaidTowardDue, 0);
|
||||
const skippedCount = rows.length - activeRows.length;
|
||||
const pct = totalThreshold > 0 ? Math.min((totalPaidTowardDue / totalThreshold) * 100, 100) : 0;
|
||||
const allPaid = pct >= 100;
|
||||
|
||||
function reorderByIndex(fromIndex, toIndex) {
|
||||
if (!reorderEnabled || fromIndex === toIndex || fromIndex < 0 || toIndex < 0) return;
|
||||
onReorderRows?.(moveInArray(rows, fromIndex, toIndex));
|
||||
}
|
||||
|
||||
function dragPropsFor(row, index) {
|
||||
if (!reorderEnabled) return { draggable: false };
|
||||
return {
|
||||
draggable: true,
|
||||
isDragging: draggingId === row.id,
|
||||
isDropTarget: dropTargetId === row.id && draggingId !== row.id,
|
||||
onDragStart: (event) => {
|
||||
setDraggingId(row.id);
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
event.dataTransfer.setData('text/plain', String(row.id));
|
||||
},
|
||||
onDragEnter: () => {
|
||||
if (draggingId && draggingId !== row.id) setDropTargetId(row.id);
|
||||
},
|
||||
onDragOver: (event) => {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = 'move';
|
||||
if (draggingId && draggingId !== row.id) setDropTargetId(row.id);
|
||||
},
|
||||
onDrop: (event) => {
|
||||
event.preventDefault();
|
||||
const sourceId = Number(event.dataTransfer.getData('text/plain') || draggingId);
|
||||
const fromIndex = rows.findIndex(item => item.id === sourceId);
|
||||
reorderByIndex(fromIndex, index);
|
||||
setDraggingId(null);
|
||||
setDropTargetId(null);
|
||||
},
|
||||
onDragEnd: () => {
|
||||
setDraggingId(null);
|
||||
setDropTargetId(null);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function moveControlsFor(row, index) {
|
||||
return {
|
||||
enabled: !!reorderEnabled,
|
||||
moving: movingBillId === row.id,
|
||||
canMoveUp: index > 0,
|
||||
canMoveDown: index < rows.length - 1,
|
||||
onMoveUp: () => reorderByIndex(index, index - 1),
|
||||
onMoveDown: () => reorderByIndex(index, index + 1),
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border/80 overflow-hidden bg-card/95 shadow-sm shadow-black/15">
|
||||
|
||||
{/* Bucket header */}
|
||||
<div className="flex items-center justify-between px-5 py-3 bg-muted/35 border-b border-border/80">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-[11px] font-bold uppercase tracking-[0.12em] text-muted-foreground">
|
||||
{label}
|
||||
</span>
|
||||
{skippedCount > 0 && (
|
||||
<span className="text-[10px] text-muted-foreground/60">
|
||||
({skippedCount} skipped)
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-1.5 w-24 rounded-full bg-border overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full transition-all duration-700',
|
||||
allPaid ? 'bg-emerald-500' : 'bg-emerald-400/70',
|
||||
)}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[11px] font-mono text-muted-foreground/70">
|
||||
{Math.round(pct)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs font-mono text-muted-foreground">
|
||||
<span>
|
||||
<span className={cn(allPaid ? 'text-emerald-500' : 'text-foreground')}>
|
||||
{fmt(totalPaidTowardDue)}
|
||||
</span>
|
||||
<span className="text-muted-foreground/50 mx-1">/</span>
|
||||
{fmt(totalThreshold)}
|
||||
{totalOverpaid > 0 && (
|
||||
<span className="ml-1 text-emerald-500">+{fmt(totalOverpaid)}</span>
|
||||
)}
|
||||
</span>
|
||||
{!allPaid && totalRemaining > 0 && (
|
||||
<span className="text-[11px] text-muted-foreground/70">{fmt(totalRemaining)} left</span>
|
||||
)}
|
||||
{allPaid && (
|
||||
<span className="text-[11px] text-emerald-500">Done</span>
|
||||
)}
|
||||
{!reorderEnabled && rows.length > 1 && (
|
||||
<span className="hidden text-[11px] text-muted-foreground/60 xl:inline">Clear filters to reorder</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 p-3 lg:hidden" aria-busy={loading ? 'true' : 'false'}>
|
||||
{loading ? (
|
||||
Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="rounded-lg border border-border/60 bg-background/60 p-3 animate-pulse">
|
||||
<div className="flex min-w-0 items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<div className="h-1.5 w-1.5 shrink-0 rounded-full bg-muted" />
|
||||
<div className="h-4 w-32 rounded-md bg-muted" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-5 w-20 rounded-md bg-muted" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-2 text-xs text-muted-foreground mt-2">
|
||||
<div>
|
||||
<p className="uppercase tracking-wide text-muted-foreground/60">Expected</p>
|
||||
<div className="h-3 w-20 rounded-md bg-muted mt-0.5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="uppercase tracking-wide text-muted-foreground/60">Remaining</p>
|
||||
<div className="h-3 w-20 rounded-md bg-muted mt-0.5" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : rows.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-border/70 bg-background/40 px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
No bills match this bucket and filter set.
|
||||
</div>
|
||||
) : (
|
||||
rows.map((r, i) => (
|
||||
<MobileTrackerRow
|
||||
key={r.id}
|
||||
row={r}
|
||||
year={year}
|
||||
month={month}
|
||||
refresh={refresh}
|
||||
index={i}
|
||||
onEditBill={onEditBill}
|
||||
moveControls={moveControlsFor(r, i)}
|
||||
dragProps={dragPropsFor(r, i)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="hidden lg:block" aria-busy={loading ? 'true' : 'false'}>
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="min-w-[1120px]">
|
||||
<TableHeader>
|
||||
<TableRow className="border-border/80 bg-background/30 hover:bg-background/30">
|
||||
<TableHead className="w-[18%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/90">Bill</TableHead>
|
||||
<TableHead className="w-[10%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/90">Due</TableHead>
|
||||
<TableHead className="w-[10%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/90 text-right">Expected</TableHead>
|
||||
<TableHead className="w-[10%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/80 text-right">Last Month</TableHead>
|
||||
<TableHead className="w-[10%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/90 text-right">Paid</TableHead>
|
||||
<TableHead className="w-[10%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/90">Paid Date</TableHead>
|
||||
<TableHead className="w-[9%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/90">Status</TableHead>
|
||||
<TableHead className="w-[10%] py-2.5" />
|
||||
<TableHead className="w-[23%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/90 border-l border-border/80 pl-4">
|
||||
Notes
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
Array.from({ length: 5 }).map((_, i) => (
|
||||
<TableRow key={i} className="border-border/50">
|
||||
<TableCell className="w-[18%] py-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-muted" />
|
||||
<div className="h-4 w-48 rounded-md bg-muted" />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="w-[10%] py-3"><div className="h-3 w-20 rounded-md bg-muted" /></TableCell>
|
||||
<TableCell className="w-[10%] py-3 text-right"><div className="h-3 w-20 ml-auto rounded-md bg-muted" /></TableCell>
|
||||
<TableCell className="w-[10%] py-3 text-right"><div className="h-3 w-20 ml-auto rounded-md bg-muted" /></TableCell>
|
||||
<TableCell className="w-[10%] py-3"><div className="h-7 w-24 ml-auto rounded-md bg-muted" /></TableCell>
|
||||
<TableCell className="w-[10%] py-3"><div className="h-7 w-24 rounded-md bg-muted" /></TableCell>
|
||||
<TableCell className="w-[9%] py-3"><div className="h-5 w-20 rounded-md bg-muted" /></TableCell>
|
||||
<TableCell className="w-[10%] py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<div className="h-7 w-20 rounded-md bg-muted" />
|
||||
<div className="h-7 w-7 rounded-md bg-muted" />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="w-[23%] py-3 border-l border-border pl-4">
|
||||
<div className="h-4 w-full rounded-md bg-muted" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : rows.length === 0 ? (
|
||||
<TableRow className="border-border/50">
|
||||
<TableCell colSpan={9} className="py-10 text-center text-sm text-muted-foreground">
|
||||
No bills match this bucket and filter set.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
rows.map((r, i) => (
|
||||
<Row
|
||||
key={r.id}
|
||||
row={r}
|
||||
year={year}
|
||||
month={month}
|
||||
refresh={refresh}
|
||||
index={i}
|
||||
onEditBill={onEditBill}
|
||||
moveControls={moveControlsFor(r, i)}
|
||||
dragProps={dragPropsFor(r, i)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,585 @@
|
|||
import React, { useState, useRef, useTransition } from 'react';
|
||||
import { ArrowDown, ArrowUp, GripVertical, Pencil, X } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/api.js';
|
||||
import { cn, fmt, fmtDate } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
TableRow, TableCell,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle,
|
||||
AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import MonthlyStateDialog from '@/components/tracker/MonthlyStateDialog';
|
||||
import PaymentModal from '@/components/tracker/PaymentModal';
|
||||
import { paymentDateForTrackerMonth, paymentSummary, ROW_STATUS_CLS } from '@/lib/trackerUtils';
|
||||
import { StatusBadge } from '@/components/tracker/StatusBadge';
|
||||
import { PaymentProgress } from '@/components/tracker/PaymentProgress';
|
||||
import { PaymentLedgerDialog } from '@/components/tracker/PaymentLedgerDialog';
|
||||
import { NotesCell } from '@/components/tracker/NotesCell';
|
||||
import { AutopaySuggestionActions } from '@/components/tracker/AutopaySuggestionActions';
|
||||
|
||||
export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveControls, dragProps }) {
|
||||
const amountRef = useRef(null);
|
||||
const [editPayment, setEditPayment] = useState(null);
|
||||
const [paymentLedgerOpen, setPaymentLedgerOpen] = useState(false);
|
||||
const [showMbs, setShowMbs] = useState(false);
|
||||
const [confirmUnpay, setConfirmUnpay] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [suggestionLoading, setSuggestionLoading] = useState(false);
|
||||
const [optimisticActual, setOptimisticActual] = useState(undefined);
|
||||
const [showUpdateNudge, setShowUpdateNudge] = useState(false);
|
||||
const [nudgeAmount, setNudgeAmount] = useState(null);
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
const [editingExpected, setEditingExpected] = useState(false);
|
||||
const [expectedDraft, setExpectedDraft] = useState('');
|
||||
const [editingDue, setEditingDue] = useState(false);
|
||||
const [dueDraft, setDueDraft] = useState('');
|
||||
|
||||
// Effective amount threshold: optimistic override → monthly override → template default.
|
||||
const effectiveActual = optimisticActual !== undefined ? optimisticActual : row.actual_amount;
|
||||
const threshold = effectiveActual != null ? effectiveActual : row.expected_amount;
|
||||
const defaultPaymentDate = paymentDateForTrackerMonth(year, month, row.due_day);
|
||||
|
||||
const isSkipped = !!row.is_skipped;
|
||||
const hasAutopaySuggestion = !!row.autopay_suggestion && !isSkipped;
|
||||
|
||||
// Paid when total payments >= effective threshold
|
||||
const isPaidByThreshold = row.total_paid > 0 && row.total_paid >= threshold;
|
||||
const isPaid = row.status === 'paid' || (row.status === 'autodraft' && !hasAutopaySuggestion) || isPaidByThreshold;
|
||||
const summary = paymentSummary(row, threshold);
|
||||
|
||||
// Effective status to show:
|
||||
// skipped > paid (threshold-based) > backend status
|
||||
const effectiveStatus = isSkipped
|
||||
? 'skipped'
|
||||
: (isPaidByThreshold && row.status !== 'paid' && row.status !== 'autodraft')
|
||||
? 'paid'
|
||||
: row.status;
|
||||
|
||||
const rowBg = isSkipped ? '' : (ROW_STATUS_CLS[effectiveStatus] || '');
|
||||
|
||||
async function handleQuickPay() {
|
||||
const val = parseFloat(amountRef.current?.value);
|
||||
if (!val || val <= 0) { toast.error('Enter a payment amount'); return; }
|
||||
try {
|
||||
await api.quickPay({ bill_id: row.id, amount: val, paid_date: defaultPaymentDate });
|
||||
toast.success('Payment added');
|
||||
refresh();
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function performTogglePaid() {
|
||||
setLoading?.(true);
|
||||
try {
|
||||
const result = await api.togglePaid(row.id, {
|
||||
amount: isPaid ? undefined : threshold,
|
||||
year: year,
|
||||
month: month,
|
||||
});
|
||||
if (isPaid && result.paymentId) {
|
||||
toast.success('Payment moved to recovery', {
|
||||
action: {
|
||||
label: 'Undo',
|
||||
onClick: async () => {
|
||||
try {
|
||||
await api.restorePayment(result.paymentId);
|
||||
toast.success('Payment restored');
|
||||
refresh?.();
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Failed to restore payment');
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
} else {
|
||||
toast.success('Payment recorded');
|
||||
}
|
||||
refresh?.();
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Failed to toggle payment status');
|
||||
} finally {
|
||||
setLoading?.(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleTogglePaid() {
|
||||
if (isPaid) {
|
||||
setConfirmUnpay(true);
|
||||
return;
|
||||
}
|
||||
performTogglePaid();
|
||||
}
|
||||
|
||||
async function handleMarkFullAmount() {
|
||||
const newActual = summary.paidTowardDue;
|
||||
setOptimisticActual(newActual);
|
||||
try {
|
||||
await api.saveBillMonthlyState(row.id, {
|
||||
year, month,
|
||||
actual_amount: newActual,
|
||||
notes: row.monthly_notes || null,
|
||||
is_skipped: row.is_skipped,
|
||||
});
|
||||
setNudgeAmount(newActual);
|
||||
setShowUpdateNudge(true);
|
||||
refresh?.();
|
||||
} catch (err) {
|
||||
setOptimisticActual(undefined);
|
||||
toast.error(err.message || 'Failed to update amount');
|
||||
}
|
||||
}
|
||||
|
||||
function handleUpdateTemplate() {
|
||||
const amount = nudgeAmount;
|
||||
setShowUpdateNudge(false);
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await api.updateBill(row.id, { name: row.name, due_day: row.due_day, expected_amount: amount });
|
||||
toast.success(`Default updated to ${fmt(amount)}`);
|
||||
refresh?.();
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Failed to update default');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function handleApplySuggestion(amount) {
|
||||
setOptimisticActual(amount);
|
||||
try {
|
||||
await api.saveBillMonthlyState(row.id, {
|
||||
year, month,
|
||||
actual_amount: amount,
|
||||
notes: row.monthly_notes || null,
|
||||
is_skipped: row.is_skipped,
|
||||
});
|
||||
refresh?.();
|
||||
} catch (err) {
|
||||
setOptimisticActual(undefined);
|
||||
toast.error(err.message || 'Failed to apply suggestion');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveExpected() {
|
||||
setEditingExpected(false);
|
||||
const val = parseFloat(expectedDraft);
|
||||
if (!isFinite(val) || val < 0) return;
|
||||
const current = effectiveActual ?? row.expected_amount;
|
||||
if (val === current) return;
|
||||
|
||||
if (effectiveActual != null) {
|
||||
setOptimisticActual(val);
|
||||
try {
|
||||
await api.saveBillMonthlyState(row.id, {
|
||||
year, month,
|
||||
actual_amount: val,
|
||||
notes: row.monthly_notes || null,
|
||||
is_skipped: row.is_skipped,
|
||||
});
|
||||
refresh?.();
|
||||
} catch (err) {
|
||||
setOptimisticActual(undefined);
|
||||
toast.error(err.message || 'Failed to update amount');
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await api.updateBill(row.id, { name: row.name, due_day: row.due_day, expected_amount: val });
|
||||
refresh?.();
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Failed to update expected amount');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveDue() {
|
||||
setEditingDue(false);
|
||||
const day = parseInt(dueDraft, 10);
|
||||
if (!isFinite(day) || day < 1 || day > 31) return;
|
||||
if (day === row.due_day) return;
|
||||
try {
|
||||
await api.updateBill(row.id, { name: row.name, due_day: day, expected_amount: row.expected_amount });
|
||||
refresh?.();
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Failed to update due date');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConfirmSuggestion() {
|
||||
setSuggestionLoading(true);
|
||||
try {
|
||||
const result = await api.confirmAutopaySuggestion(row.id, { year, month });
|
||||
toast.success(result.created ? 'Autopay payment confirmed' : 'Autopay already recorded');
|
||||
refresh?.();
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Failed to confirm autopay suggestion');
|
||||
} finally {
|
||||
setSuggestionLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDismissSuggestion() {
|
||||
setSuggestionLoading(true);
|
||||
try {
|
||||
await api.dismissAutopaySuggestion(row.id, { year, month });
|
||||
toast.success('Autopay suggestion dismissed');
|
||||
refresh?.();
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Failed to dismiss autopay suggestion');
|
||||
} finally {
|
||||
setSuggestionLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableRow
|
||||
draggable={dragProps?.draggable}
|
||||
onDragStart={dragProps?.onDragStart}
|
||||
onDragEnter={dragProps?.onDragEnter}
|
||||
onDragOver={dragProps?.onDragOver}
|
||||
onDragEnd={dragProps?.onDragEnd}
|
||||
onDrop={dragProps?.onDrop}
|
||||
className={cn(
|
||||
'group border-border/65 transition-colors duration-150',
|
||||
isSkipped ? 'opacity-40' : 'hover:bg-accent/50',
|
||||
rowBg,
|
||||
dragProps?.isDragging && 'opacity-45',
|
||||
dragProps?.isDropTarget && 'ring-2 ring-inset ring-primary/35',
|
||||
)}
|
||||
style={{ animationDelay: `${index * 40}ms` }}
|
||||
>
|
||||
{/* Bill name + category + monthly notes (if set) */}
|
||||
<TableCell className="w-[18%] py-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
<GripVertical
|
||||
className={cn(
|
||||
'h-4 w-4 text-muted-foreground/55',
|
||||
moveControls?.enabled && 'cursor-grab group-active:cursor-grabbing',
|
||||
!moveControls?.enabled && 'opacity-30',
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="hidden flex-col sm:flex">
|
||||
<button
|
||||
type="button"
|
||||
onClick={moveControls?.onMoveUp}
|
||||
disabled={!moveControls?.enabled || !moveControls?.canMoveUp || moveControls?.moving}
|
||||
className="rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-25"
|
||||
title="Move bill up"
|
||||
aria-label={`Move ${row.name} up`}
|
||||
>
|
||||
<ArrowUp className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={moveControls?.onMoveDown}
|
||||
disabled={!moveControls?.enabled || !moveControls?.canMoveDown || moveControls?.moving}
|
||||
className="rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-25"
|
||||
title="Move bill down"
|
||||
aria-label={`Move ${row.name} down`}
|
||||
>
|
||||
<ArrowDown className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-1">
|
||||
{row.website ? (
|
||||
<a
|
||||
href={row.website}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={cn(
|
||||
'text-[15px] font-semibold leading-tight text-foreground transition-colors',
|
||||
'hover:underline decoration-muted-foreground/50 underline-offset-2',
|
||||
isSkipped && 'line-through',
|
||||
)}
|
||||
>
|
||||
{row.name}
|
||||
</a>
|
||||
) : (
|
||||
<span className={cn('text-[15px] font-semibold leading-tight text-foreground', isSkipped && 'line-through')}>
|
||||
{row.name}
|
||||
</span>
|
||||
)}
|
||||
{row.autopay_enabled && (
|
||||
<span
|
||||
className="inline-flex shrink-0 rounded border border-sky-500/25 bg-sky-500/10 px-1.5 py-0.5 text-[10px] font-bold leading-none text-sky-600 dark:text-sky-300"
|
||||
title="Autopay"
|
||||
>
|
||||
AP
|
||||
</span>
|
||||
)}
|
||||
{row.is_subscription && (
|
||||
<span
|
||||
className="inline-flex shrink-0 rounded border border-indigo-500/25 bg-indigo-500/10 px-1.5 py-0.5 text-[10px] font-bold leading-none text-indigo-600 dark:text-indigo-300"
|
||||
title="Subscription"
|
||||
>
|
||||
S
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
size="icon" variant="ghost"
|
||||
className="h-7 w-7 opacity-100 transition-opacity text-muted-foreground hover:text-foreground hover:bg-accent lg:opacity-0 lg:group-hover:opacity-100"
|
||||
title="Edit bill"
|
||||
onClick={() => onEditBill?.(row)}
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
{row.category_name && (
|
||||
<p className="mt-0.5 text-xs text-muted-foreground/85">{row.category_name}</p>
|
||||
)}
|
||||
{/* Monthly notes shown inline under the bill name */}
|
||||
{row.monthly_notes && (
|
||||
<p className="text-[11px] text-amber-500/80 mt-0.5 italic truncate max-w-[140px]"
|
||||
title={row.monthly_notes}>
|
||||
{row.monthly_notes}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
{/* Due */}
|
||||
<TableCell className="tracker-number w-[10%] py-3 text-[13px] font-medium text-foreground/75">
|
||||
{editingDue ? (
|
||||
<input
|
||||
type="number"
|
||||
min="1" max="31"
|
||||
value={dueDraft}
|
||||
autoFocus
|
||||
onChange={e => setDueDraft(e.target.value)}
|
||||
onBlur={handleSaveDue}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') e.currentTarget.blur();
|
||||
if (e.key === 'Escape') { setEditingDue(false); }
|
||||
}}
|
||||
className="tracker-number w-12 rounded border border-border bg-transparent px-1 py-0.5 text-sm font-medium text-foreground outline-none focus:ring-[2px] focus:ring-ring/50"
|
||||
title="Day of month (1–31)"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setDueDraft(String(row.due_day)); setEditingDue(true); }}
|
||||
className="rounded px-1 py-0.5 transition-colors hover:bg-accent hover:text-foreground"
|
||||
title="Click to edit due day"
|
||||
>
|
||||
{fmtDate(row.due_date)}
|
||||
</button>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
{/* Expected / Actual — shows actual_amount in amber when it overrides the template */}
|
||||
<TableCell className="tracker-number w-[10%] py-3 text-right text-[13px] font-semibold">
|
||||
{editingExpected ? (
|
||||
<input
|
||||
type="number"
|
||||
min="0" step="0.01"
|
||||
value={expectedDraft}
|
||||
autoFocus
|
||||
onChange={e => setExpectedDraft(e.target.value)}
|
||||
onBlur={handleSaveExpected}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') e.currentTarget.blur();
|
||||
if (e.key === 'Escape') { setEditingExpected(false); }
|
||||
}}
|
||||
className="tracker-number w-24 rounded border border-border bg-transparent px-1 py-0.5 text-right text-sm font-semibold text-foreground outline-none focus:ring-[2px] focus:ring-ring/50"
|
||||
/>
|
||||
) : effectiveActual != null ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setExpectedDraft(String(effectiveActual)); setEditingExpected(true); }}
|
||||
className="rounded px-1 py-0.5 text-amber-300 transition-colors hover:bg-accent"
|
||||
title={`Monthly override — click to edit. Template default: ${fmt(row.expected_amount)}`}
|
||||
>
|
||||
{fmt(effectiveActual)}
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex flex-col items-end gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setExpectedDraft(String(row.expected_amount)); setEditingExpected(true); }}
|
||||
className="rounded px-1 py-0.5 text-foreground/85 transition-colors hover:bg-accent hover:text-foreground"
|
||||
title="Click to edit expected amount"
|
||||
>
|
||||
{fmt(row.expected_amount)}
|
||||
</button>
|
||||
{row.amount_suggestion?.suggestion != null &&
|
||||
Math.abs(row.amount_suggestion.suggestion - row.expected_amount) / row.expected_amount > 0.05 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleApplySuggestion(row.amount_suggestion.suggestion)}
|
||||
className="text-[10px] text-muted-foreground/50 transition-colors hover:text-muted-foreground"
|
||||
title={`Based on last ${row.amount_suggestion.months_used} months`}
|
||||
>
|
||||
~{fmt(row.amount_suggestion.suggestion)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
{/* Previous month paid */}
|
||||
<TableCell className="tracker-number w-[10%] py-3 text-right text-[13px] font-medium text-muted-foreground/80">
|
||||
{row.previous_month_paid > 0 ? fmt(row.previous_month_paid) : '—'}
|
||||
</TableCell>
|
||||
|
||||
{/* Amount paid — mismatch now compares against threshold */}
|
||||
<TableCell className="w-[10%] py-3 text-right">
|
||||
<PaymentProgress
|
||||
row={row}
|
||||
threshold={threshold}
|
||||
onOpen={() => setPaymentLedgerOpen(true)}
|
||||
onMarkFullAmount={!isSkipped ? handleMarkFullAmount : undefined}
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
{/* Paid date */}
|
||||
<TableCell className="w-[10%] py-3 text-[13px] text-foreground/75">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPaymentLedgerOpen(true)}
|
||||
className="tracker-number rounded-md px-1.5 py-0.5 font-medium transition-colors hover:bg-accent hover:text-foreground"
|
||||
title="View payment history"
|
||||
>
|
||||
{row.last_paid_date ? fmtDate(row.last_paid_date) : '—'}
|
||||
{summary.count > 1 && <span className="ml-1 text-[10px] text-muted-foreground">({summary.count})</span>}
|
||||
</button>
|
||||
</TableCell>
|
||||
|
||||
{/* Status — uses effectiveStatus (accounts for skipped + threshold) */}
|
||||
<TableCell className="w-[9%] py-3">
|
||||
<StatusBadge
|
||||
status={effectiveStatus}
|
||||
clickable
|
||||
onClick={() => {
|
||||
if (effectiveStatus === 'skipped') return;
|
||||
handleTogglePaid();
|
||||
}}
|
||||
loading={loading}
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
{/* Actions */}
|
||||
<TableCell className="w-[10%] py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{showUpdateNudge ? (
|
||||
<div className="flex items-center gap-1 animate-in fade-in slide-in-from-right-1 duration-200">
|
||||
<span className="text-[10px] text-muted-foreground">Update default?</span>
|
||||
<Button
|
||||
size="sm" variant="ghost"
|
||||
onClick={handleUpdateTemplate}
|
||||
className="h-6 px-2 text-[10px] font-semibold text-emerald-600 hover:bg-emerald-500/10 hover:text-emerald-700 dark:text-emerald-400 dark:hover:text-emerald-300"
|
||||
>
|
||||
{fmt(nudgeAmount)}
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowUpdateNudge(false)}
|
||||
className="text-muted-foreground transition-colors hover:text-foreground"
|
||||
title="Dismiss"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{hasAutopaySuggestion && (
|
||||
<AutopaySuggestionActions
|
||||
row={row}
|
||||
loading={suggestionLoading}
|
||||
onConfirm={handleConfirmSuggestion}
|
||||
onDismiss={handleDismissSuggestion}
|
||||
/>
|
||||
)}
|
||||
{/* Quick pay — hidden for skipped/paid bills */}
|
||||
{!isPaid && !isSkipped && !hasAutopaySuggestion && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
ref={amountRef}
|
||||
type="number" min="0" step="0.01"
|
||||
defaultValue={summary.remaining || threshold}
|
||||
className="tracker-number h-7 w-20 text-right text-sm font-medium bg-background/50 border-border/50"
|
||||
title="Payment amount"
|
||||
/>
|
||||
<Button
|
||||
size="sm" variant="ghost"
|
||||
onClick={handleQuickPay}
|
||||
className="h-7 px-2.5 text-xs font-semibold text-emerald-600 hover:text-emerald-700 hover:bg-emerald-50 dark:text-emerald-400 dark:hover:text-emerald-300 dark:hover:bg-emerald-500/10"
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
{/* Notes cell (monthly state notes) */}
|
||||
<TableCell className="w-[23%] py-3 border-l border-border pl-4">
|
||||
<NotesCell row={{ ...row, year, month }} refresh={refresh} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
{editPayment && (
|
||||
<PaymentModal
|
||||
payment={editPayment}
|
||||
onClose={() => setEditPayment(null)}
|
||||
onSave={refresh}
|
||||
/>
|
||||
)}
|
||||
|
||||
{paymentLedgerOpen && (
|
||||
<PaymentLedgerDialog
|
||||
row={row}
|
||||
year={year}
|
||||
month={month}
|
||||
threshold={threshold}
|
||||
defaultPaymentDate={defaultPaymentDate}
|
||||
onClose={() => setPaymentLedgerOpen(false)}
|
||||
onSaved={refresh}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showMbs && (
|
||||
<MonthlyStateDialog
|
||||
row={row}
|
||||
year={year}
|
||||
month={month}
|
||||
open={showMbs}
|
||||
onOpenChange={setShowMbs}
|
||||
onSaved={refresh}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AlertDialog open={confirmUnpay} onOpenChange={setConfirmUnpay}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Mark this bill unpaid?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This removes the current payment record for this month and moves it into recovery.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={loading}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
disabled={loading}
|
||||
onClick={performTogglePaid}
|
||||
>
|
||||
{loading ? 'Removing...' : 'Remove Payment'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -17,7 +17,7 @@ export function AuthProvider({ children }) {
|
|||
useEffect(() => {
|
||||
api.authMode().then(d => {
|
||||
if (d.auth_mode === 'single') setSUM(true);
|
||||
}).catch(() => {});
|
||||
}).catch(err => console.error('[useAuth] authMode check failed', err));
|
||||
|
||||
api.me().then(applyMeResponse).catch(() => setUser(null));
|
||||
}, []); // eslint-disable-line
|
||||
|
|
|
|||
|
|
@ -0,0 +1,106 @@
|
|||
import { todayStr } from '@/lib/utils';
|
||||
|
||||
export const MONTHS = [
|
||||
'January','February','March','April','May','June',
|
||||
'July','August','September','October','November','December',
|
||||
];
|
||||
export const FILTER_ALL = 'all';
|
||||
// Sentinel for the "no method" select option — empty string crashes Radix Select
|
||||
export const METHOD_NONE = 'none';
|
||||
|
||||
export const ROW_STATUS_CLS = {
|
||||
paid: 'bg-emerald-500/[0.04] dark:bg-emerald-400/[0.02]',
|
||||
autodraft: 'bg-sky-500/[0.04] dark:bg-sky-400/[0.018]',
|
||||
upcoming: '',
|
||||
due_soon: 'bg-amber-400/[0.07] dark:bg-amber-300/[0.016]',
|
||||
late: 'border-l-4 border-l-orange-400 bg-orange-500/[0.16] ring-1 ring-inset ring-orange-400/25 dark:bg-orange-400/[0.11] dark:ring-orange-300/25',
|
||||
missed: 'border-l-4 border-l-rose-400 bg-rose-500/[0.18] ring-1 ring-inset ring-rose-400/30 dark:bg-rose-400/[0.13] dark:ring-rose-300/30',
|
||||
};
|
||||
|
||||
export const STATUS_META = {
|
||||
paid: { label: 'Paid', cls: 'bg-emerald-500/15 text-emerald-500 border border-emerald-500/30 dark:bg-emerald-300/10 dark:text-emerald-200 dark:border-emerald-300/30' },
|
||||
upcoming: { label: 'Upcoming', cls: 'bg-secondary text-muted-foreground border border-border' },
|
||||
due_soon: { label: 'Due Soon', cls: 'bg-amber-400/15 text-amber-500 border border-amber-400/30 dark:bg-amber-300/10 dark:text-amber-200 dark:border-amber-300/28' },
|
||||
late: { label: 'Late', cls: 'bg-orange-500/30 text-orange-800 border border-orange-500/60 shadow-sm shadow-orange-950/10 dark:bg-orange-400/25 dark:text-orange-100 dark:border-orange-300/60' },
|
||||
missed: { label: 'Missed', cls: 'bg-rose-500/30 text-rose-800 border border-rose-500/70 shadow-sm shadow-rose-950/10 dark:bg-rose-400/25 dark:text-rose-100 dark:border-rose-300/60' },
|
||||
autodraft: { label: 'Autodraft', cls: 'bg-sky-400/15 text-sky-500 border border-sky-400/30 dark:bg-sky-300/10 dark:text-sky-200 dark:border-sky-300/28' },
|
||||
skipped: { label: 'Skipped', cls: 'bg-muted text-muted-foreground border border-border' },
|
||||
};
|
||||
|
||||
export function paymentDateForTrackerMonth(year, month, dueDay) {
|
||||
const now = new Date();
|
||||
if (year === now.getFullYear() && month === now.getMonth() + 1) {
|
||||
return todayStr();
|
||||
}
|
||||
|
||||
const daysInMonth = new Date(year, month, 0).getDate();
|
||||
const day = Number.isInteger(Number(dueDay))
|
||||
? Math.min(Math.max(Number(dueDay), 1), daysInMonth)
|
||||
: 1;
|
||||
|
||||
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function amountSearchText(...values) {
|
||||
return values
|
||||
.filter(value => value !== null && value !== undefined && Number.isFinite(Number(value)))
|
||||
.flatMap(value => {
|
||||
const num = Number(value);
|
||||
return [String(num), num.toFixed(2), `$${num.toFixed(2)}`];
|
||||
})
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
export function rowThreshold(row) {
|
||||
return row.actual_amount != null ? row.actual_amount : row.expected_amount;
|
||||
}
|
||||
|
||||
export function rowEffectiveStatus(row) {
|
||||
if (row.is_skipped) return 'skipped';
|
||||
const threshold = rowThreshold(row);
|
||||
const isPaidByThreshold = row.total_paid > 0 && row.total_paid >= threshold;
|
||||
return (isPaidByThreshold && row.status !== 'paid' && row.status !== 'autodraft') ? 'paid' : row.status;
|
||||
}
|
||||
|
||||
export function rowIsPaid(row) {
|
||||
const status = rowEffectiveStatus(row);
|
||||
if (row.autopay_suggestion && status === 'autodraft') return false;
|
||||
return status === 'paid' || status === 'autodraft';
|
||||
}
|
||||
|
||||
export function rowIsDebt(row) {
|
||||
const category = String(row.category_name || '').toLowerCase();
|
||||
return Number(row.current_balance) > 0
|
||||
|| row.minimum_payment != null
|
||||
|| ['credit card', 'credit cards', 'loan', 'loans', 'debt', 'mortgage'].some(token => category.includes(token));
|
||||
}
|
||||
|
||||
export function moveInArray(items, fromIndex, toIndex) {
|
||||
const next = [...items];
|
||||
const [moved] = next.splice(fromIndex, 1);
|
||||
next.splice(toIndex, 0, moved);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function paymentSummary(row, threshold) {
|
||||
const target = Number(threshold) || 0;
|
||||
const paid = Number(row.total_paid) || 0;
|
||||
const paidTowardDue = Number.isFinite(Number(row.paid_toward_due))
|
||||
? Number(row.paid_toward_due)
|
||||
: Math.min(paid, target);
|
||||
const overpaid = Number.isFinite(Number(row.overpaid_amount))
|
||||
? Number(row.overpaid_amount)
|
||||
: Math.max(paid - target, 0);
|
||||
const remaining = Math.max(target - paidTowardDue, 0);
|
||||
const percent = target > 0 ? Math.min(100, Math.round((paidTowardDue / target) * 100)) : 0;
|
||||
return {
|
||||
target,
|
||||
paid,
|
||||
paidTowardDue,
|
||||
overpaid,
|
||||
remaining,
|
||||
percent,
|
||||
count: Array.isArray(row.payments) ? row.payments.length : 0,
|
||||
partial: paid > 0 && remaining > 0,
|
||||
};
|
||||
}
|
||||
|
|
@ -40,13 +40,13 @@ export default function LoginPage() {
|
|||
setAuthMode(d);
|
||||
if (d.auth_mode === 'single') navigate('/', { replace: true });
|
||||
})
|
||||
.catch(() => {});
|
||||
.catch(err => console.error('[LoginPage] authMode check failed', err));
|
||||
|
||||
api.me()
|
||||
.then(d => {
|
||||
if (d.user) navigate(destFor(d.user), { replace: true });
|
||||
})
|
||||
.catch(() => {});
|
||||
.catch(err => console.error('[LoginPage] session check failed', err));
|
||||
}, []); // eslint-disable-line
|
||||
|
||||
const handlePostLogin = (user) => {
|
||||
|
|
|
|||
|
|
@ -387,7 +387,7 @@ export default function SnowballPage() {
|
|||
|
||||
const loadPlans = useCallback(() => {
|
||||
api.snowballActivePlan().then(p => setActivePlan(p)).catch(() => setActivePlan(null));
|
||||
api.snowballPlans().then(d => setAllPlans(d?.plans ?? [])).catch(() => {});
|
||||
api.snowballPlans().then(d => setAllPlans(d?.plans ?? [])).catch(err => console.error('[SnowballPage] failed to load plan history', err));
|
||||
}, []);
|
||||
|
||||
useEffect(() => { Promise.all([load(), loadProjection()]); loadPlans(); }, [load, loadProjection, loadPlans]);
|
||||
|
|
|
|||
|
|
@ -412,7 +412,7 @@ export default function SubscriptionsPage() {
|
|||
useEffect(() => {
|
||||
load();
|
||||
loadRecommendations();
|
||||
api.allBills().then(b => setBills(Array.isArray(b) ? b : [])).catch(() => {});
|
||||
api.allBills().then(b => setBills(Array.isArray(b) ? b : [])).catch(err => console.error('[SubscriptionsPage] failed to load bills', err));
|
||||
}, [load, loadRecommendations]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -545,7 +545,7 @@ export default function SubscriptionsPage() {
|
|||
await api.reorderBills(reorderPayload(nextBills));
|
||||
toast.success('Subscription order saved');
|
||||
await load();
|
||||
api.allBills().then(b => setBills(Array.isArray(b) ? b : [])).catch(() => {});
|
||||
api.allBills().then(b => setBills(Array.isArray(b) ? b : [])).catch(err => console.error('[SubscriptionsPage] failed to refresh bills after reorder', err));
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Failed to save subscription order');
|
||||
await load();
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -2561,6 +2561,28 @@ function runMigrations() {
|
|||
END;
|
||||
`);
|
||||
}
|
||||
},
|
||||
{
|
||||
version: 'v0.77',
|
||||
description: 'encrypt SMTP password at rest',
|
||||
dependsOn: ['v0.76'],
|
||||
run: function() {
|
||||
try {
|
||||
const { decryptSecret, encryptSecret } = require('../services/encryptionService');
|
||||
const row = db.prepare("SELECT value FROM settings WHERE key = 'notify_smtp_password'").get();
|
||||
if (row?.value) {
|
||||
try {
|
||||
decryptSecret(row.value); // already encrypted — skip
|
||||
} catch {
|
||||
// plaintext — encrypt it
|
||||
db.prepare("UPDATE settings SET value = ?, updated_at = datetime('now') WHERE key = 'notify_smtp_password'")
|
||||
.run(encryptSecret(row.value));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[v0.77] SMTP password encryption migration failed:', err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "bill-tracker",
|
||||
"version": "0.34.2",
|
||||
"version": "0.34.3",
|
||||
"description": "Monthly bill tracking system",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -160,23 +160,28 @@ router.post('/users', async (req, res) => {
|
|||
if (db.prepare('SELECT id FROM users WHERE username = ?').get(username))
|
||||
return res.status(409).json({ error: 'Username already taken' });
|
||||
|
||||
const hash = await hashPassword(password);
|
||||
const result = db.prepare(
|
||||
"INSERT INTO users (username, password_hash, role, first_login) VALUES (?, ?, 'user', 1)"
|
||||
).run(username, hash);
|
||||
try {
|
||||
const hash = await hashPassword(password);
|
||||
const result = db.prepare(
|
||||
"INSERT INTO users (username, password_hash, role, first_login) VALUES (?, ?, 'user', 1)"
|
||||
).run(username, hash);
|
||||
|
||||
const created = db.prepare(
|
||||
'SELECT id, username, role, active, is_default_admin, must_change_password, first_login, created_at FROM users WHERE id = ?'
|
||||
).get(result.lastInsertRowid);
|
||||
const created = db.prepare(
|
||||
'SELECT id, username, role, active, is_default_admin, must_change_password, first_login, created_at FROM users WHERE id = ?'
|
||||
).get(result.lastInsertRowid);
|
||||
|
||||
logAudit({
|
||||
user_id: req.user.id, action: 'admin.user.create',
|
||||
entity_type: 'user', entity_id: created.id,
|
||||
details: { created_username: username },
|
||||
ip_address: req.ip, user_agent: req.get('user-agent'),
|
||||
});
|
||||
logAudit({
|
||||
user_id: req.user.id, action: 'admin.user.create',
|
||||
entity_type: 'user', entity_id: created.id,
|
||||
details: { created_username: username },
|
||||
ip_address: req.ip, user_agent: req.get('user-agent'),
|
||||
});
|
||||
|
||||
res.status(201).json(created);
|
||||
res.status(201).json(created);
|
||||
} catch (err) {
|
||||
console.error('[admin] create-user error:', err.message);
|
||||
res.status(500).json({ error: 'Failed to create user' });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/admin/users/:id/password
|
||||
|
|
@ -187,21 +192,26 @@ router.put('/users/:id/password', async (req, res) => {
|
|||
|
||||
const db = getDb();
|
||||
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id);
|
||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||
|
||||
const hash = await hashPassword(password);
|
||||
db.prepare("UPDATE users SET password_hash=?, must_change_password=1, updated_at=datetime('now') WHERE id=?")
|
||||
.run(hash, req.params.id);
|
||||
db.prepare('DELETE FROM sessions WHERE user_id = ?').run(req.params.id);
|
||||
try {
|
||||
const hash = await hashPassword(password);
|
||||
db.prepare("UPDATE users SET password_hash=?, must_change_password=1, updated_at=datetime('now') WHERE id=?")
|
||||
.run(hash, req.params.id);
|
||||
db.prepare('DELETE FROM sessions WHERE user_id = ?').run(req.params.id);
|
||||
|
||||
logAudit({
|
||||
user_id: req.user.id, action: 'admin.password.reset',
|
||||
entity_type: 'user', entity_id: Number(req.params.id),
|
||||
details: { target_username: user.username },
|
||||
ip_address: req.ip, user_agent: req.get('user-agent'),
|
||||
});
|
||||
logAudit({
|
||||
user_id: req.user.id, action: 'admin.password.reset',
|
||||
entity_type: 'user', entity_id: Number(req.params.id),
|
||||
details: { target_username: user.username },
|
||||
ip_address: req.ip, user_agent: req.get('user-agent'),
|
||||
});
|
||||
|
||||
res.json({ success: true });
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error('[admin] reset-password error:', err.message);
|
||||
res.status(500).json({ error: 'Failed to reset password' });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/admin/users/:id/role
|
||||
|
|
@ -315,10 +325,20 @@ router.delete('/users/:id', (req, res) => {
|
|||
if (req.user?.id === user.id) return res.status(400).json({ error: 'You cannot delete your own account.' });
|
||||
|
||||
const deleteUser = db.transaction(() => {
|
||||
// These three tables have no FK/CASCADE to users — must delete explicitly.
|
||||
// Sessions also has CASCADE but we keep the explicit delete as a safety net
|
||||
// for the rare case where foreign_keys is temporarily OFF during a migration.
|
||||
db.prepare('DELETE FROM import_sessions WHERE user_id = ?').run(user.id);
|
||||
db.prepare('DELETE FROM import_history WHERE user_id = ?').run(user.id);
|
||||
db.prepare('DELETE FROM audit_log WHERE user_id = ?').run(user.id);
|
||||
db.prepare('DELETE FROM sessions WHERE user_id = ?').run(user.id);
|
||||
db.prepare('DELETE FROM users WHERE id = ?').run(user.id);
|
||||
// ON DELETE CASCADE handles: bills, payments, categories, monthly_bill_state,
|
||||
// bill_history_ranges, notifications, data_sources, financial_accounts,
|
||||
// transactions, user_settings, user_login_history, monthly_income,
|
||||
// monthly_starting_amounts, autopay_suggestion_dismissals, bill_templates,
|
||||
// match_suggestion_rejections, declined_subscription_hints, bill_merchant_rules,
|
||||
// snowball_plans.
|
||||
});
|
||||
deleteUser();
|
||||
res.json({ success: true, deleted_user_id: user.id });
|
||||
|
|
|
|||
|
|
@ -164,33 +164,38 @@ router.post('/change-password', passwordLimiter, requireAuth, async (req, res) =
|
|||
const db = getDb();
|
||||
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);
|
||||
|
||||
if (!user.must_change_password) {
|
||||
const bcrypt = require('bcryptjs');
|
||||
const valid = await bcrypt.compare(current_password || '', user.password_hash);
|
||||
if (!valid) return res.status(401).json(standardizeError('Current password is incorrect', 'AUTH_ERROR', 'current_password'));
|
||||
}
|
||||
|
||||
const hash = await hashPassword(new_password);
|
||||
|
||||
db.prepare(
|
||||
"UPDATE users SET password_hash = ?, must_change_password = 0, last_password_change_at = datetime('now'), updated_at = datetime('now') WHERE id = ?"
|
||||
).run(hash, req.user.id);
|
||||
|
||||
// Invalidate all other sessions for this user
|
||||
const currentSessionId = req.cookies?.[COOKIE_NAME];
|
||||
if (currentSessionId) {
|
||||
invalidateOtherSessions(req.user.id, currentSessionId);
|
||||
|
||||
// Rotate the current session ID for security
|
||||
const newSessionId = rotateSessionId(currentSessionId, req.user.id);
|
||||
if (newSessionId) {
|
||||
res.cookie(COOKIE_NAME, newSessionId, cookieOpts(req));
|
||||
try {
|
||||
if (!user.must_change_password) {
|
||||
const bcrypt = require('bcryptjs');
|
||||
const valid = await bcrypt.compare(current_password || '', user.password_hash);
|
||||
if (!valid) return res.status(401).json(standardizeError('Current password is incorrect', 'AUTH_ERROR', 'current_password'));
|
||||
}
|
||||
|
||||
const hash = await hashPassword(new_password);
|
||||
|
||||
db.prepare(
|
||||
"UPDATE users SET password_hash = ?, must_change_password = 0, last_password_change_at = datetime('now'), updated_at = datetime('now') WHERE id = ?"
|
||||
).run(hash, req.user.id);
|
||||
|
||||
// Invalidate all other sessions for this user
|
||||
const currentSessionId = req.cookies?.[COOKIE_NAME];
|
||||
if (currentSessionId) {
|
||||
invalidateOtherSessions(req.user.id, currentSessionId);
|
||||
|
||||
// Rotate the current session ID for security
|
||||
const newSessionId = rotateSessionId(currentSessionId, req.user.id);
|
||||
if (newSessionId) {
|
||||
res.cookie(COOKIE_NAME, newSessionId, cookieOpts(req));
|
||||
}
|
||||
}
|
||||
|
||||
logAudit({ user_id: req.user.id, action: 'password.change', ip_address: req.ip, user_agent: req.get('user-agent') });
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error('[auth] change-password error:', err.message);
|
||||
res.status(500).json(standardizeError('Password change failed', 'SERVER_ERROR'));
|
||||
}
|
||||
|
||||
logAudit({ user_id: req.user.id, action: 'password.change', ip_address: req.ip, user_agent: req.get('user-agent') });
|
||||
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────
|
||||
|
|
@ -232,17 +237,22 @@ router.post('/users', requireAuth, requireAdmin, async (req, res) => {
|
|||
const existing = db.prepare('SELECT id FROM users WHERE username = ?').get(username);
|
||||
if (existing) return res.status(409).json(standardizeError('Username already taken', 'CONFLICT', 'username'));
|
||||
|
||||
const hash = await hashPassword(password);
|
||||
try {
|
||||
const hash = await hashPassword(password);
|
||||
|
||||
const result = db.prepare(
|
||||
"INSERT INTO users (username, password_hash, role, first_login, last_seen_version) VALUES (?, ?, 'user', 1, ?)"
|
||||
).run(username, hash, getAppVersion());
|
||||
const result = db.prepare(
|
||||
"INSERT INTO users (username, password_hash, role, first_login, last_seen_version) VALUES (?, ?, 'user', 1, ?)"
|
||||
).run(username, hash, getAppVersion());
|
||||
|
||||
const created = db.prepare(
|
||||
'SELECT id, username, role, must_change_password, first_login, created_at FROM users WHERE id = ?'
|
||||
).get(result.lastInsertRowid);
|
||||
const created = db.prepare(
|
||||
'SELECT id, username, role, must_change_password, first_login, created_at FROM users WHERE id = ?'
|
||||
).get(result.lastInsertRowid);
|
||||
|
||||
res.status(201).json(created);
|
||||
res.status(201).json(created);
|
||||
} catch (err) {
|
||||
console.error('[auth] create-user error:', err.message);
|
||||
res.status(500).json(standardizeError('Failed to create user', 'SERVER_ERROR'));
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ function parseYearMonth(source) {
|
|||
|
||||
function money(value) {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
return Number.isFinite(n) ? Math.round(n * 100) / 100 : 0;
|
||||
}
|
||||
|
||||
function getStartingAmounts(db, userId, year, month) {
|
||||
|
|
@ -94,7 +94,7 @@ function buildStartingAmountsResponse(db, userId, year, month) {
|
|||
const amounts = getStartingAmounts(db, userId, year, month);
|
||||
const paid = calculatePaidDeductions(db, userId, year, month);
|
||||
|
||||
const combined_amount = amounts.first_amount + amounts.fifteenth_amount + amounts.other_amount;
|
||||
const combined_amount = money(amounts.first_amount + amounts.fifteenth_amount + amounts.other_amount);
|
||||
const paid_total = paid.paid_total;
|
||||
|
||||
return {
|
||||
|
|
@ -108,10 +108,10 @@ function buildStartingAmountsResponse(db, userId, year, month) {
|
|||
paid_from_fifteenth: paid.paid_from_fifteenth,
|
||||
paid_from_other: paid.paid_from_other,
|
||||
paid_total,
|
||||
first_remaining: amounts.first_amount - paid.paid_from_first,
|
||||
fifteenth_remaining: amounts.fifteenth_amount - paid.paid_from_fifteenth,
|
||||
other_remaining: amounts.other_amount - paid.paid_from_other,
|
||||
combined_remaining: combined_amount - paid_total,
|
||||
first_remaining: money(amounts.first_amount - paid.paid_from_first),
|
||||
fifteenth_remaining: money(amounts.fifteenth_amount - paid.paid_from_fifteenth),
|
||||
other_remaining: money(amounts.other_amount - paid.paid_from_other),
|
||||
combined_remaining: money(combined_amount - paid_total),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ const router = express.Router();
|
|||
const { getDb, getSetting, setSetting } = require('../db/database');
|
||||
const { requireAuth, requireUser, requireAdmin } = require('../middleware/requireAuth');
|
||||
const { sendTestEmail } = require('../services/notificationService');
|
||||
const { encryptSecret } = require('../services/encryptionService');
|
||||
|
||||
// ── Admin: SMTP configuration ─────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -34,7 +35,7 @@ router.put('/admin', requireAuth, requireAdmin, (req, res) => {
|
|||
}
|
||||
// Only update password if a real value was sent (not the masked placeholder)
|
||||
if (req.body.notify_smtp_password && !req.body.notify_smtp_password.startsWith('•')) {
|
||||
setSetting('notify_smtp_password', req.body.notify_smtp_password);
|
||||
setSetting('notify_smtp_password', encryptSecret(req.body.notify_smtp_password));
|
||||
}
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -401,6 +401,7 @@ router.put('/:id', (req, res) => {
|
|||
amount = ?, paid_date = ?, method = ?, notes = ?, balance_delta = ?, payment_source = ?,
|
||||
updated_at = datetime('now')
|
||||
WHERE id = ?
|
||||
AND bill_id IN (SELECT id FROM bills WHERE user_id = ? AND deleted_at IS NULL)
|
||||
`).run(
|
||||
nextAmount,
|
||||
nextPaidDate,
|
||||
|
|
@ -409,9 +410,10 @@ router.put('/:id', (req, res) => {
|
|||
nextBalanceDelta,
|
||||
nextPaymentSource,
|
||||
req.params.id,
|
||||
req.user.id,
|
||||
);
|
||||
|
||||
res.json(db.prepare('SELECT * FROM payments WHERE id = ?').get(req.params.id));
|
||||
res.json(db.prepare(`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${LIVE} AND b.user_id = ? AND b.deleted_at IS NULL`).get(req.params.id, req.user.id));
|
||||
});
|
||||
|
||||
// DELETE /api/payments/:id — soft delete (sets deleted_at)
|
||||
|
|
@ -423,14 +425,14 @@ router.delete('/:id', (req, res) => {
|
|||
|
||||
// Reverse any balance delta that was stored when this payment was created
|
||||
if (payment.balance_delta != null) {
|
||||
const bill = db.prepare('SELECT current_balance FROM bills WHERE id = ?').get(payment.bill_id);
|
||||
const bill = db.prepare('SELECT current_balance FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(payment.bill_id, req.user.id);
|
||||
if (bill?.current_balance != null) {
|
||||
const restored = Math.max(0, Math.round((bill.current_balance - payment.balance_delta) * 100) / 100);
|
||||
db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?").run(restored, payment.bill_id);
|
||||
}
|
||||
}
|
||||
|
||||
db.prepare("UPDATE payments SET deleted_at = datetime('now') WHERE id = ?").run(req.params.id);
|
||||
db.prepare("UPDATE payments SET deleted_at = datetime('now') WHERE id = ? AND bill_id IN (SELECT id FROM bills WHERE user_id = ? AND deleted_at IS NULL)").run(req.params.id, req.user.id);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
|
|
@ -443,15 +445,15 @@ router.post('/:id/restore', (req, res) => {
|
|||
|
||||
// Re-apply the balance delta (undo the reversal done on delete)
|
||||
if (payment.balance_delta != null) {
|
||||
const bill = db.prepare('SELECT current_balance FROM bills WHERE id = ?').get(payment.bill_id);
|
||||
const bill = db.prepare('SELECT current_balance FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(payment.bill_id, req.user.id);
|
||||
if (bill?.current_balance != null) {
|
||||
const reapplied = Math.max(0, Math.round((bill.current_balance + payment.balance_delta) * 100) / 100);
|
||||
db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?").run(reapplied, payment.bill_id);
|
||||
}
|
||||
}
|
||||
|
||||
db.prepare('UPDATE payments SET deleted_at = NULL WHERE id = ?').run(req.params.id);
|
||||
res.json(db.prepare('SELECT * FROM payments WHERE id = ?').get(req.params.id));
|
||||
db.prepare('UPDATE payments SET deleted_at = NULL WHERE id = ? AND bill_id IN (SELECT id FROM bills WHERE user_id = ? AND deleted_at IS NULL)').run(req.params.id, req.user.id);
|
||||
res.json(db.prepare(`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${LIVE} AND b.user_id = ? AND b.deleted_at IS NULL`).get(req.params.id, req.user.id));
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
|
|
|||
|
|
@ -237,36 +237,41 @@ router.post('/change-password', passwordLimiter, async (req, res) => {
|
|||
const user = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(req.user.id);
|
||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||
|
||||
const valid = await bcrypt.compare(current_password, user.password_hash);
|
||||
if (!valid) {
|
||||
return res.status(401).json({ error: 'current password is incorrect' });
|
||||
}
|
||||
|
||||
const hash = await hashPassword(new_password);
|
||||
|
||||
db.prepare(`
|
||||
UPDATE users
|
||||
SET password_hash = ?, must_change_password = 0,
|
||||
last_password_change_at = datetime('now'),
|
||||
updated_at = datetime('now')
|
||||
WHERE id = ?
|
||||
`).run(hash, req.user.id);
|
||||
|
||||
// Invalidate all other sessions for this user
|
||||
const currentSessionId = req.cookies?.[COOKIE_NAME];
|
||||
if (currentSessionId) {
|
||||
invalidateOtherSessions(req.user.id, currentSessionId);
|
||||
|
||||
// Rotate the current session ID for security
|
||||
const newSessionId = rotateSessionId(currentSessionId, req.user.id);
|
||||
if (newSessionId) {
|
||||
res.cookie(COOKIE_NAME, newSessionId, cookieOpts(req));
|
||||
try {
|
||||
const valid = await bcrypt.compare(current_password, user.password_hash);
|
||||
if (!valid) {
|
||||
return res.status(401).json({ error: 'current password is incorrect' });
|
||||
}
|
||||
|
||||
const hash = await hashPassword(new_password);
|
||||
|
||||
db.prepare(`
|
||||
UPDATE users
|
||||
SET password_hash = ?, must_change_password = 0,
|
||||
last_password_change_at = datetime('now'),
|
||||
updated_at = datetime('now')
|
||||
WHERE id = ?
|
||||
`).run(hash, req.user.id);
|
||||
|
||||
// Invalidate all other sessions for this user
|
||||
const currentSessionId = req.cookies?.[COOKIE_NAME];
|
||||
if (currentSessionId) {
|
||||
invalidateOtherSessions(req.user.id, currentSessionId);
|
||||
|
||||
// Rotate the current session ID for security
|
||||
const newSessionId = rotateSessionId(currentSessionId, req.user.id);
|
||||
if (newSessionId) {
|
||||
res.cookie(COOKIE_NAME, newSessionId, cookieOpts(req));
|
||||
}
|
||||
}
|
||||
|
||||
logAudit({ user_id: req.user.id, action: 'password.change', ip_address: req.ip, user_agent: req.get('user-agent') });
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error('[profile] change-password error:', err.message);
|
||||
res.status(500).json({ error: 'Password change failed' });
|
||||
}
|
||||
|
||||
logAudit({ user_id: req.user.id, action: 'password.change', ip_address: req.ip, user_agent: req.get('user-agent') });
|
||||
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// ── GET /api/profile/exports ──────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ function parseYearMonth(source) {
|
|||
|
||||
function money(value) {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
return Number.isFinite(n) ? Math.round(n * 100) / 100 : 0;
|
||||
}
|
||||
|
||||
function getStartingAmounts(db, userId, year, month) {
|
||||
|
|
@ -100,7 +100,7 @@ function buildStartingAmountsSummary(db, userId, year, month) {
|
|||
const amounts = getStartingAmounts(db, userId, year, month);
|
||||
const paid = calculatePaidDeductions(db, userId, year, month);
|
||||
|
||||
const combined_amount = amounts.first_amount + amounts.fifteenth_amount + amounts.other_amount;
|
||||
const combined_amount = money(amounts.first_amount + amounts.fifteenth_amount + amounts.other_amount);
|
||||
const paid_total = paid.paid_total;
|
||||
|
||||
return {
|
||||
|
|
@ -114,10 +114,10 @@ function buildStartingAmountsSummary(db, userId, year, month) {
|
|||
paid_from_fifteenth: paid.paid_from_fifteenth,
|
||||
paid_from_other: paid.paid_from_other,
|
||||
paid_total,
|
||||
first_remaining: amounts.first_amount - paid.paid_from_first,
|
||||
fifteenth_remaining: amounts.fifteenth_amount - paid.paid_from_fifteenth,
|
||||
other_remaining: amounts.other_amount - paid.paid_from_other,
|
||||
combined_remaining: combined_amount - paid_total,
|
||||
first_remaining: money(amounts.first_amount - paid.paid_from_first),
|
||||
fifteenth_remaining: money(amounts.fifteenth_amount - paid.paid_from_fifteenth),
|
||||
other_remaining: money(amounts.other_amount - paid.paid_from_other),
|
||||
combined_remaining: money(combined_amount - paid_total),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -207,12 +207,12 @@ function buildSummary(db, userId, year, month) {
|
|||
|
||||
const countedExpenses = expenses.filter(expense => !expense.is_skipped);
|
||||
const incomeTotal = money(income.amount);
|
||||
const expenseTotal = countedExpenses.reduce((sum, expense) => sum + money(expense.display_amount), 0);
|
||||
const paidTotal = countedExpenses.reduce((sum, expense) => sum + money(expense.paid_amount), 0);
|
||||
const expenseTotal = money(countedExpenses.reduce((sum, expense) => sum + expense.display_amount, 0));
|
||||
const paidTotal = money(countedExpenses.reduce((sum, expense) => sum + expense.paid_amount, 0));
|
||||
const paidExpenseCount = countedExpenses.filter(expense => expense.is_paid).length;
|
||||
const starting_amounts = buildStartingAmountsSummary(db, userId, year, month);
|
||||
const planBaseTotal = money(starting_amounts.combined_amount);
|
||||
const result = planBaseTotal - expenseTotal;
|
||||
const result = money(planBaseTotal - expenseTotal);
|
||||
|
||||
// Previous month context
|
||||
let previous_month = null;
|
||||
|
|
@ -254,7 +254,7 @@ function buildSummary(db, userId, year, month) {
|
|||
paid_expense_count: paidExpenseCount,
|
||||
expense_count: countedExpenses.length,
|
||||
paid_total: starting_amounts.paid_total,
|
||||
remaining_expense_total: Math.max(0, expenseTotal - paidTotal),
|
||||
remaining_expense_total: money(Math.max(0, expenseTotal - paidTotal)),
|
||||
result,
|
||||
},
|
||||
chart: [
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ if (process.env.CORS_ORIGIN) {
|
|||
app.use(cors({ credentials: true, origin: allowed }));
|
||||
}
|
||||
|
||||
app.use(express.json());
|
||||
app.use(express.json({ limit: '100kb' })); // import routes override this per-endpoint
|
||||
app.use(cookieParser());
|
||||
|
||||
// ── CSRF token provider - sets CSRF cookie on every response ────────────────
|
||||
|
|
@ -151,6 +151,11 @@ app.use((err, req, res, next) => {
|
|||
});
|
||||
});
|
||||
|
||||
// ── Safety net: log unhandled promise rejections instead of crashing ─────────
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
console.error('[server] Unhandled promise rejection:', reason?.message || reason);
|
||||
});
|
||||
|
||||
// ── Bootstrap ─────────────────────────────────────────────────────────────────
|
||||
async function main() {
|
||||
const db = getDb();
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
const nodemailer = require('nodemailer');
|
||||
const { getDb, getSetting } = require('../db/database');
|
||||
const { decryptSecret } = require('./encryptionService');
|
||||
const {
|
||||
markNotificationError,
|
||||
markNotificationSuccess,
|
||||
|
|
@ -8,12 +9,22 @@ const {
|
|||
|
||||
// ── SMTP transport ────────────────────────────────────────────────────────────
|
||||
|
||||
function getSmtpPassword() {
|
||||
const stored = getSetting('notify_smtp_password');
|
||||
if (!stored) return '';
|
||||
try {
|
||||
return decryptSecret(stored);
|
||||
} catch {
|
||||
return stored; // legacy plaintext — works until re-saved via admin UI
|
||||
}
|
||||
}
|
||||
|
||||
function createTransport() {
|
||||
const host = getSetting('notify_smtp_host');
|
||||
const port = parseInt(getSetting('notify_smtp_port') || '587', 10);
|
||||
const encryption = getSetting('notify_smtp_encryption') || 'starttls';
|
||||
const username = getSetting('notify_smtp_username');
|
||||
const password = getSetting('notify_smtp_password');
|
||||
const password = getSmtpPassword();
|
||||
const selfSigned = getSetting('notify_smtp_self_signed') === 'true';
|
||||
|
||||
if (!host) throw new Error('SMTP host is not configured');
|
||||
|
|
|
|||
|
|
@ -277,4 +277,5 @@ module.exports = {
|
|||
resolveBucket,
|
||||
resolveDueDate,
|
||||
resolveGracePeriodDays,
|
||||
roundMoney,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
'use strict';
|
||||
|
||||
const { getDb } = require('../db/database');
|
||||
const { buildTrackerRow, getCycleRange, resolveDueDate } = require('./statusService');
|
||||
const { buildTrackerRow, getCycleRange, resolveDueDate, roundMoney } = require('./statusService');
|
||||
const { getUserSettings } = require('./userSettings');
|
||||
const { computeBalanceDelta } = require('./billsService');
|
||||
const { computeAmountSuggestion } = require('./amountSuggestionService');
|
||||
|
|
@ -288,8 +288,8 @@ function getTracker(userId, query = {}, now = new Date()) {
|
|||
const dayOfMonth = now.getDate();
|
||||
const activeRemainingPeriod = dayOfMonth < 15 ? '1st' : '15th';
|
||||
const periodRows = activeRows.filter(r => r.bucket === activeRemainingPeriod);
|
||||
const periodPaidTowardDue = periodRows.reduce((s, r) => s + rowPaidTowardDue(r), 0);
|
||||
const periodOutstandingBalance = periodRows.reduce((s, r) => s + Math.max(r.balance || 0, 0), 0);
|
||||
const periodPaidTowardDue = roundMoney(periodRows.reduce((s, r) => s + rowPaidTowardDue(r), 0));
|
||||
const periodOutstandingBalance = roundMoney(periodRows.reduce((s, r) => s + Math.max(r.balance || 0, 0), 0));
|
||||
const periodStartingAmount = activeRemainingPeriod === '1st'
|
||||
? (startingAmounts?.first_amount || 0)
|
||||
: (startingAmounts?.fifteenth_amount || 0);
|
||||
|
|
@ -297,14 +297,14 @@ function getTracker(userId, query = {}, now = new Date()) {
|
|||
|
||||
const totalStarting = startingAmounts?.combined_amount || 0;
|
||||
const hasStartingAmounts = !!startingAmounts;
|
||||
const activeTotalPaid = activeRows.reduce((s, r) => s + r.total_paid, 0);
|
||||
const activePaidTowardDue = activeRows.reduce((s, r) => s + rowPaidTowardDue(r), 0);
|
||||
const activeTotalExpected = activeRows.reduce((s, r) => s + rowDueAmount(r), 0);
|
||||
const activeOutstandingBalance = activeRows.reduce((s, r) => s + Math.max(r.balance || 0, 0), 0);
|
||||
const totalOverdue = rows
|
||||
const activeTotalPaid = roundMoney(activeRows.reduce((s, r) => s + r.total_paid, 0));
|
||||
const activePaidTowardDue = roundMoney(activeRows.reduce((s, r) => s + rowPaidTowardDue(r), 0));
|
||||
const activeTotalExpected = roundMoney(activeRows.reduce((s, r) => s + rowDueAmount(r), 0));
|
||||
const activeOutstandingBalance = roundMoney(activeRows.reduce((s, r) => s + Math.max(r.balance || 0, 0), 0));
|
||||
const totalOverdue = roundMoney(rows
|
||||
.filter(r => !r.is_skipped && (r.status === 'late' || r.status === 'missed'))
|
||||
.reduce((s, r) => s + r.balance, 0);
|
||||
const previousMonthTotal = activeRows.reduce((s, r) => s + r.previous_month_paid, 0);
|
||||
.reduce((s, r) => s + r.balance, 0));
|
||||
const previousMonthTotal = roundMoney(activeRows.reduce((s, r) => s + r.previous_month_paid, 0));
|
||||
|
||||
return {
|
||||
year,
|
||||
|
|
@ -316,8 +316,8 @@ function getTracker(userId, query = {}, now = new Date()) {
|
|||
has_starting_amounts: hasStartingAmounts,
|
||||
total_paid: activeTotalPaid,
|
||||
paid_toward_due: activePaidTowardDue,
|
||||
remaining: hasStartingAmounts ? periodStartingAmount - periodPaidTowardDue : periodOutstandingBalance,
|
||||
total_remaining: hasStartingAmounts ? totalStarting - activePaidTowardDue : activeOutstandingBalance,
|
||||
remaining: roundMoney(hasStartingAmounts ? periodStartingAmount - periodPaidTowardDue : periodOutstandingBalance),
|
||||
total_remaining: roundMoney(hasStartingAmounts ? totalStarting - activePaidTowardDue : activeOutstandingBalance),
|
||||
remaining_period: activeRemainingPeriod,
|
||||
remaining_label: periodLabel,
|
||||
remaining_hint: hasStartingAmounts
|
||||
|
|
|
|||
Loading…
Reference in New Issue