Initial commit

This commit is contained in:
michaelswanson
2026-06-25 19:58:40 +00:00
commit 860d5f55cc
47 changed files with 19216 additions and 0 deletions
+165
View File
@@ -0,0 +1,165 @@
import { useState } from 'react';
import { Pencil, Trash2, ChevronDown, ChevronUp, Plus, CreditCard } from 'lucide-react';
import { fmt, estimatedPayoff, today, parseAmount } from '../utils.js';
import Modal from './Modal.jsx';
function PaymentForm({ loan, onSave, onClose }) {
const [amount, setAmount] = useState(loan.monthly_payment ?? '');
const [date, setDate] = useState(today());
const [notes, setNotes] = useState('');
function submit(e) {
e.preventDefault();
if (isNaN(parseAmount(amount))) return;
onSave({ amount: parseAmount(amount), payment_date: date, notes: notes.trim() || null });
onClose();
}
return (
<form onSubmit={submit} className="space-y-3">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Belopp (kr)</label>
<input
type="text" inputMode="decimal"
className="w-full border border-slate-200 rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={amount} onChange={e => setAmount(e.target.value)} required autoFocus
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Datum</label>
<input
type="date"
className="w-full border border-slate-200 rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={date} onChange={e => setDate(e.target.value)}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Anteckning (valfri)</label>
<input
className="w-full border border-slate-200 rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={notes} onChange={e => setNotes(e.target.value)} placeholder="Valfri notering"
/>
</div>
<div className="flex gap-2 pt-1">
<button type="button" onClick={onClose} className="flex-1 py-2.5 rounded-xl border border-slate-200 text-sm font-medium text-slate-600 hover:bg-slate-50">
Avbryt
</button>
<button type="submit" className="flex-1 py-2.5 rounded-xl bg-green-600 text-white text-sm font-medium hover:bg-green-700">
Registrera
</button>
</div>
</form>
);
}
export default function LoanCard({ loan, payments = [], onPayment, onEdit, onDelete }) {
const [expanded, setExpanded] = useState(false);
const [payModal, setPayModal] = useState(false);
const progress = loan.original_amount > 0
? Math.min(100, Math.round(((loan.original_amount - loan.current_balance) / loan.original_amount) * 100))
: 0;
const payoff = estimatedPayoff(loan.current_balance, loan.monthly_payment, loan.interest_rate);
const isPaidOff = loan.current_balance <= 0;
return (
<div className={`bg-white rounded-2xl shadow-sm border overflow-hidden mb-3 ${!loan.is_active ? 'opacity-60' : 'border-slate-200'}`}>
<div className="px-4 py-4">
<div className="mb-3 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 flex items-center gap-2">
<CreditCard size={16} className="text-blue-500 shrink-0 mt-0.5" />
<div className="min-w-0">
<h3 className="truncate font-semibold text-slate-800 text-sm">{loan.name}</h3>
{loan.notes && <p className="text-xs text-slate-400 mt-0.5">{loan.notes}</p>}
</div>
</div>
<div className="flex flex-wrap gap-1 shrink-0">
{loan.is_active && (
<button
onClick={() => setPayModal(true)}
className="flex items-center gap-1 text-xs bg-green-50 text-green-600 px-2.5 py-1 rounded-lg font-medium hover:bg-green-100"
>
<Plus size={12} /> Betala
</button>
)}
<button onClick={onEdit} className="p-1.5 rounded-lg hover:bg-slate-100 text-slate-400">
<Pencil size={14} />
</button>
<button onClick={onDelete} className="p-1.5 rounded-lg hover:bg-red-50 text-slate-400 hover:text-red-500">
<Trash2 size={14} />
</button>
</div>
</div>
<div className="mb-2.5">
<div className="flex justify-between text-xs text-slate-500 mb-1.5">
<span>{isPaidOff ? 'Betalt!' : `Saldo: ${fmt(loan.current_balance)}`}</span>
<span className="font-medium text-slate-700">{progress}% avbetalt</span>
</div>
<div className="h-2.5 bg-slate-100 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-500"
style={{
width: `${progress}%`,
backgroundColor: isPaidOff ? '#16A34A' : progress > 75 ? '#22C55E' : progress > 40 ? '#3B82F6' : '#6366F1',
}}
/>
</div>
</div>
<div className="grid grid-cols-1 gap-2 text-center sm:grid-cols-3">
<div className="bg-slate-50 rounded-xl px-2 py-1.5">
<p className="text-xs text-slate-400">Ursprung</p>
<p className="text-xs font-semibold text-slate-700 mt-0.5">{fmt(loan.original_amount)}</p>
</div>
<div className="bg-slate-50 rounded-xl px-2 py-1.5">
<p className="text-xs text-slate-400">Månadsbet.</p>
<p className="text-xs font-semibold text-slate-700 mt-0.5">{fmt(loan.monthly_payment)}</p>
</div>
<div className="bg-slate-50 rounded-xl px-2 py-1.5">
<p className="text-xs text-slate-400">Klar ca</p>
<p className="text-xs font-semibold text-slate-700 mt-0.5">{payoff ?? ''}</p>
</div>
</div>
{loan.interest_rate > 0 && (
<p className="text-xs text-slate-400 mt-2">Ränta: {loan.interest_rate}%</p>
)}
</div>
{payments.length > 0 && (
<>
<button
onClick={() => setExpanded(e => !e)}
className="w-full flex items-center justify-between px-4 py-2 border-t border-slate-100 text-xs text-slate-500 hover:bg-slate-50 transition-colors"
>
<span>Betalningshistorik ({payments.length})</span>
{expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</button>
{expanded && (
<div className="border-t border-slate-100 divide-y divide-slate-50 max-h-48 overflow-y-auto">
{payments.map(p => (
<div key={p.id} className="flex items-center justify-between px-4 py-2">
<span className="text-xs text-slate-500">{p.payment_date}</span>
{p.notes && <span className="text-xs text-slate-400 flex-1 mx-2 truncate">{p.notes}</span>}
<span className="text-xs font-semibold text-green-600">{fmt(p.amount)}</span>
</div>
))}
</div>
)}
</>
)}
{payModal && (
<Modal title={`Registrera betalning ${loan.name}`} onClose={() => setPayModal(false)}>
<PaymentForm
loan={loan}
onSave={(data) => { onPayment(loan.id, data); setPayModal(false); }}
onClose={() => setPayModal(false)}
/>
</Modal>
)}
</div>
);
}