Initial commit
This commit is contained in:
Executable
+162
@@ -0,0 +1,162 @@
|
||||
import { useState } from 'react';
|
||||
import { Plus, Pencil, Trash2, TrendingUp } from 'lucide-react';
|
||||
import { fmt, parseAmount } from '../utils.js';
|
||||
import Modal from './Modal.jsx';
|
||||
|
||||
const TYPE_LABELS = {
|
||||
salary: { label: 'Lön', color: 'bg-blue-100 text-blue-700' },
|
||||
benefit: { label: 'Förmån', color: 'bg-purple-100 text-purple-700' },
|
||||
optional: { label: 'Valfri', color: 'bg-amber-100 text-amber-700' },
|
||||
other: { label: 'Övrigt', color: 'bg-slate-100 text-slate-600' },
|
||||
};
|
||||
|
||||
function IncomeForm({ initial, onSave, onClose }) {
|
||||
const [name, setName] = useState(initial?.name ?? '');
|
||||
const [amount, setAmount] = useState(initial?.amount ?? '');
|
||||
const [type, setType] = useState(initial?.type ?? 'salary');
|
||||
const [notes, setNotes] = useState(initial?.notes ?? '');
|
||||
|
||||
function submit(e) {
|
||||
e.preventDefault();
|
||||
if (!name.trim() || isNaN(parseAmount(amount))) return;
|
||||
onSave({ name: name.trim(), amount: parseAmount(amount), type, 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">Namn</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={name} onChange={e => setName(e.target.value)} placeholder="t.ex. Lön" required autoFocus
|
||||
/>
|
||||
</div>
|
||||
<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)} placeholder="0" required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Typ</label>
|
||||
<select
|
||||
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={type} onChange={e => setType(e.target.value)}
|
||||
>
|
||||
<option value="salary">Lön</option>
|
||||
<option value="benefit">Förmån (t.ex. ePassi)</option>
|
||||
<option value="optional">Valfri inkomst</option>
|
||||
<option value="other">Övrigt</option>
|
||||
</select>
|
||||
</div>
|
||||
{type === 'benefit' && (
|
||||
<p className="text-xs text-purple-600 bg-purple-50 rounded-lg px-3 py-2">
|
||||
Förmåner visas separat och påverkar inte den ordinarie balansen.
|
||||
</p>
|
||||
)}
|
||||
{type === 'optional' && (
|
||||
<p className="text-xs text-amber-600 bg-amber-50 rounded-lg px-3 py-2">
|
||||
Valfri inkomst visas separat i sammanfattningen.
|
||||
</p>
|
||||
)}
|
||||
<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-blue-600 text-white text-sm font-medium hover:bg-blue-700">
|
||||
Spara
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export default function IncomeSection({ income, monthId, onAdd, onUpdate, onDelete }) {
|
||||
const [modal, setModal] = useState(null); // null | 'add' | {edit: item}
|
||||
|
||||
const mandatory = income.filter(i => i.type !== 'optional' && i.type !== 'benefit');
|
||||
const optional = income.filter(i => i.type === 'optional');
|
||||
const benefits = income.filter(i => i.type === 'benefit');
|
||||
const totalMandatory = mandatory.reduce((s, i) => s + i.amount, 0);
|
||||
|
||||
return (
|
||||
<section className="bg-white rounded-2xl shadow-sm border border-slate-200 overflow-hidden mb-3">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-100">
|
||||
<div className="flex items-center gap-2">
|
||||
<TrendingUp size={16} className="text-green-500" />
|
||||
<span className="font-semibold text-slate-700 text-sm uppercase tracking-wide">Inkomster</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setModal('add')}
|
||||
className="flex items-center gap-1 text-blue-600 text-xs font-medium hover:text-blue-700"
|
||||
>
|
||||
<Plus size={14} /> Lägg till
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-slate-50">
|
||||
{income.map(item => {
|
||||
const tInfo = TYPE_LABELS[item.type] ?? TYPE_LABELS.other;
|
||||
return (
|
||||
<div key={item.id} className="flex items-center gap-3 px-4 py-2.5 group">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full font-medium shrink-0 ${tInfo.color}`}>{tInfo.label}</span>
|
||||
<span className="flex-1 text-sm text-slate-700 truncate">{item.name}</span>
|
||||
{item.notes && <span className="text-xs text-slate-400 truncate max-w-[100px] hidden sm:block">{item.notes}</span>}
|
||||
<span className="text-sm font-semibold text-slate-800 shrink-0">{fmt(item.amount)}</span>
|
||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
|
||||
<button onClick={() => setModal({ edit: item })} className="p-1 rounded hover:bg-slate-100 text-slate-400">
|
||||
<Pencil size={13} />
|
||||
</button>
|
||||
<button onClick={() => onDelete(item.id)} className="p-1 rounded hover:bg-red-50 text-slate-400 hover:text-red-500">
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{income.length === 0 && (
|
||||
<p className="px-4 py-3 text-sm text-slate-400 italic">Inga inkomster tillagda</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-2.5 bg-slate-50 border-t border-slate-100 flex justify-between items-center">
|
||||
<span className="text-xs text-slate-500">
|
||||
{optional.length > 0 && `+ ${fmt(optional.reduce((s, i) => s + i.amount, 0))} valfri`}
|
||||
{benefits.length > 0 && ` + ${fmt(benefits.reduce((s, i) => s + i.amount, 0))} förmån`}
|
||||
</span>
|
||||
<div className="text-right">
|
||||
<span className="text-xs text-slate-500 mr-2">Obligatorisk inkomst</span>
|
||||
<span className="font-bold text-green-600">{fmt(totalMandatory)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{modal === 'add' && (
|
||||
<Modal title="Lägg till inkomst" onClose={() => setModal(null)}>
|
||||
<IncomeForm
|
||||
onSave={(data) => onAdd(monthId, data)}
|
||||
onClose={() => setModal(null)}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
{modal?.edit && (
|
||||
<Modal title="Redigera inkomst" onClose={() => setModal(null)}>
|
||||
<IncomeForm
|
||||
initial={modal.edit}
|
||||
onSave={(data) => onUpdate(modal.edit.id, data)}
|
||||
onClose={() => setModal(null)}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user