@@ -20,6 +20,13 @@ function ProgressBar({ actual, planned }) {
) ;
}
// "YYYY-MM" um n Monate verschieben (rein clientseitig, für die Nebeneinander-Ansicht).
function shiftLabel ( label , n ) {
const [ y , m ] = label . split ( "-" ) . map ( Number ) ;
const d = new Date ( y , m - 1 + n , 1 ) ;
return ` ${ d . getFullYear ( ) } - ${ String ( d . getMonth ( ) + 1 ) . padStart ( 2 , "0" ) } ` ;
}
export default function Budget ( ) {
const { t } = useTranslation ( ) ;
const { locale } = useSettings ( ) ;
@@ -27,21 +34,24 @@ export default function Budget() {
const toast = useToast ( ) ;
const [ mode , setMode ] = useState ( "month" ) ; // "month" | "template"
const [ view , setView ] = useState ( null ) ;
const [ label , setLabel ] = useState ( null ) ;
const [ views , setViews ] = useState ( null ) ; // Array von Monatsansichten (1..3)
const [ anchor , setAnchor ] = useState ( null ) ; // Label des ersten (linken) Monats
const [ count , setCount ] = useState ( 1 ) ;
const [ groups , setGroups ] = useState ( [ ] ) ;
const [ accounts , setAccounts ] = useState ( [ ] ) ;
const [ template , setTemplate ] = useState ( { lines : [ ] , income : [ ] } ) ;
const [ incomeModal , setIncomeModal ] = useState ( null ) ;
const base = view ? . base _currency || "EUR" ;
const base = views ? . [ 0 ] ? . base _currency || "EUR" ;
const fmt = ( c ) => formatMoney ( c , base ) ;
async function loadView ( lbl ) {
async function loadViews ( anchorLbl , n ) {
try {
const v = lbl ? await api . budget ( lbl ) : await api . budgetCurrent ( ) ;
setView ( v ) ;
setLabel ( v . label ) ;
const first = anchorLbl ? await api . budget ( anchorLbl ) : await api . budgetCurrent ( ) ;
const rest = [ ] ;
for ( let k = 1 ; k < n ; k ++ ) rest . push ( await api . budget ( shiftLabel ( first . label , k ) ) ) ;
setViews ( [ first , ... rest ] ) ;
setAnchor ( first . label ) ;
} catch ( err ) { toast ( err . message , "error" ) ; }
}
async function loadTemplate ( ) {
@@ -54,7 +64,7 @@ export default function Budget() {
} catch ( err ) { toast ( err . message , "error" ) ; }
}
useEffect ( ( ) => { loadStamm ( ) ; loadView ( ) ; } , [ ] ) ;
useEffect ( ( ) => { loadStamm ( ) ; loadViews ( null , 1 ) ; } , [ ] ) ;
useEffect ( ( ) => { if ( mode === "template" ) loadTemplate ( ) ; } , [ mode ] ) ;
const tplMap = useMemo (
@@ -67,16 +77,32 @@ export default function Budget() {
[ groups ]
) ;
async function saveMonth ( catId , cents ) {
try { setView ( await api . setMonthCategory ( label , catId , cents ) ) ; } catch ( err ) { toast ( err . message , "error" ) ; }
function replaceView ( v ) {
setViews ( ( vs ) => vs . map ( ( x ) => ( x . label === v . label ? v : x ) ) ) ;
}
function changeCount ( n ) { setCount ( n ) ; loadViews ( anchor , n ) ; }
async function saveMonth ( lbl , catId , cents ) {
try { replaceView ( await api . setMonthCategory ( lbl , catId , cents ) ) ; } catch ( err ) { toast ( err . message , "error" ) ; }
}
async function resetMonth ( lbl ) {
if ( ! ( await confirm ( { title : t ( "budget.resetTitle" ) , message : t ( "budget.resetConfirm" ) , confirmLabel : t ( "budget.reset" ) } ) ) ) return ;
try { replaceView ( await api . resetMonth ( lbl ) ) ; toast ( t ( "settings.saved" ) ) ; } catch ( err ) { toast ( err . message , "error" ) ; }
}
async function copyMonthToTemplate ( lbl ) {
if ( ! ( await confirm ( { title : t ( "budget.setAsTemplate" ) , message : t ( "budget.setAsTemplateConfirm" ) , confirmLabel : t ( "budget.setAsTemplate" ) } ) ) ) return ;
try { await api . budgetTemplateFromMonth ( lbl ) ; toast ( t ( "settings.saved" ) ) ; if ( mode === "template" ) loadTemplate ( ) ; }
catch ( err ) { toast ( err . message , "error" ) ; }
}
async function copyTemplateToMonth ( lbl ) {
if ( ! ( await confirm ( { title : t ( "budget.copyFromTemplate" ) , message : t ( "budget.copyFromTemplateConfirm" ) , confirmLabel : t ( "budget.copyFromTemplate" ) , danger : true } ) ) ) return ;
try { replaceView ( await api . budgetMonthFromTemplate ( lbl ) ) ; toast ( t ( "settings.saved" ) ) ; }
catch ( err ) { toast ( err . message , "error" ) ; }
}
async function saveTemplate ( catId , cents ) {
try { await api . setTemplateCategory ( catId , cents ) ; loadTemplate ( ) ; } catch ( err ) { toast ( err . message , "error" ) ; }
}
async function resetMonth ( ) {
if ( ! ( await confirm ( { title : t ( "budget.resetTitle" ) , message : t ( "budget.resetConfirm" ) , confirmLabel : t ( "budget.reset" ) } ) ) ) return ;
try { setView ( await api . resetMonth ( label ) ) ; toast ( t ( "settings.saved" ) ) ; } catch ( err ) { toast ( err . message , "error" ) ; }
}
async function saveIncome ( e ) {
e . preventDefault ( ) ;
@@ -92,17 +118,77 @@ export default function Budget() {
else await api . createIncome ( body ) ;
setIncomeModal ( null ) ;
loadTemplate ( ) ;
loadView ( label ) ;
loadViews ( anchor , count ) ;
} catch ( err ) { toast ( err . message , "error" ) ; }
}
async function deleteIncome ( id ) {
if ( ! ( await confirm ( { title : t ( "common.delete" ) , message : t ( "budget.deleteIncomeConfirm" ) , danger : true , confirmLabel : t ( "common.delete" ) } ) ) ) return ;
try { await api . deleteIncome ( id ) ; loadTemplate ( ) ; loadView ( label ) ; } catch ( err ) { toast ( err . message , "error" ) ; }
try { await api . deleteIncome ( id ) ; loadTemplate ( ) ; loadViews ( anchor , count ) ; } catch ( err ) { toast ( err . message , "error" ) ; }
}
if ( ! view ) return < div className = "muted" > { t ( "common.loading" ) } < / div > ;
function MonthColumn ( { view : v } ) {
const totv = v . totals ;
return (
< div className = "card budget-col" >
< div className = "budget-col-head" >
< strong className = "grow ellipsis" > { formatMonthLabel ( v . label , locale ) } < / strong >
< button className = "btn sm ghost" title = { t ( "budget.copyFromTemplate" ) } onClick = { ( ) => copyTemplateToMonth ( v . label ) } >
< Icon name = "download" size = { 14 } / >
< / button >
< button className = "btn sm ghost" title = { t ( "budget.setAsTemplate" ) } onClick = { ( ) => copyMonthToTemplate ( v . label ) } >
< Icon name = "upload" size = { 14 } / >
< / button >
{ v . customized && (
< button className = "btn sm ghost" title = { t ( "budget.reset" ) } onClick = { ( ) => resetMonth ( v . label ) } >
< Icon name = "refresh-ccw" size = { 14 } / >
< / button >
) }
< / div >
const tot = view . totals ;
{ v . income . length > 0 && (
< div className = "budget-col-income" >
< span className = "grow muted small" > { t ( "budget.income" ) } < / span >
< span className = { ` amount small ${ totv . received _income >= totv . planned _income ? "pos" : "neg" } ` } > { fmt ( totv . received _income ) } < / span >
< span className = "muted small" > / { fmt ( totv . planned _income ) } < / span >
< / div >
) }
{ v . groups . map ( ( g ) => (
< div className = "budget-group" key = { g . id } >
< div className = "budget-group-head" >
< IconChip name = { g . icon } color = { g . color } size = { 22 } / >
< strong className = "grow ellipsis" > { g . name } < / strong >
< span className = { ` amount small ${ g . over ? "neg" : "muted" } ` } > { fmt ( g . actual _cents ) } < / span >
< span className = "muted small" > / { fmt ( g . planned _cents ) } < / span >
< / div >
< ProgressBar actual = { g . actual _cents } planned = { g . planned _cents } / >
{ g . categories . map ( ( c ) => (
< div className = "budget-crow" key = { c . category _id } >
< IconChip name = { c . icon } color = { c . color } size = { 20 } / >
< span className = "grow ellipsis" > { c . name } < / span >
< span className = { ` amount small ${ c . over ? "neg" : "muted" } ` } > { fmt ( c . actual _cents ) } < / span >
< EditableAmount cents = { c . planned _cents } onSave = { ( cents ) => saveMonth ( v . label , c . category _id , cents ) } / >
< / div >
) ) }
< / div >
) ) }
< div className = "budget-total-row card-total" >
< span className = "grow" > < strong > { t ( "budget.totalExpense" ) } < / strong > < / span >
< span className = { ` amount ${ totv . actual _expense > totv . planned _expense ? "neg" : "" } ` } > { fmt ( totv . actual _expense ) } < / span >
< span className = "muted" > / { fmt ( totv . planned _expense ) } < / span >
< / div >
< / div >
) ;
}
if ( ! views ) return < div className = "muted" > { t ( "common.loading" ) } < / div > ;
const first = views [ 0 ] ? . label ;
const last = views [ views . length - 1 ] ? . label ;
const rangeTitle = views . length === 1
? formatMonthLabel ( first , locale )
: ` ${ formatMonthLabel ( first , locale ) } – ${ formatMonthLabel ( last , locale ) } ` ;
return (
< div >
@@ -121,76 +207,24 @@ export default function Budget() {
{ mode === "month" ? (
< >
< div className = "card budget-navbar" >
< button className = "btn ghost" onClick = { ( ) => loadView ( view . prev ) } >
< button className = "btn ghost" onClick = { ( ) => loadViews ( shiftLabel ( anchor , - 1 ) , count ) } title = { t ( "budget.prevMonth" ) } >
< Icon name = "chevron-right" size = { 16 } className = "flip" / >
< / button >
< strong className = "budget-month" > { formatMonthLabel ( view . label , locale ) } < / strong >
< button className = "btn ghost" onClick = { ( ) => loadView ( view . next ) } >
< strong className = "budget-month" > { rangeTitle } < / strong >
< button className = "btn ghost" onClick = { ( ) => loadViews ( shiftLabel ( anchor , 1 ) , count ) } title = { t ( "budget.nextMonth" ) } >
< Icon name = "chevron-right" size = { 16 } / >
< / button >
< div className = "grow" / >
{ view . customized && (
< button className = "btn sm" onClick = { resetMonth } >
< Icon name = "refresh-ccw" size = { 14 } / > { t ( "budget.reset" ) }
< / button >
) }
< span className = "muted small" > { t ( "budget.monthsShown" ) } < / span >
< div className = "seg sm" style = { { width : "auto" } } >
{ [ 1 , 2 , 3 ] . map ( ( n ) => (
< button key = { n } type = "button" className = { ` seg-btn ${ count === n ? " active" : "" } ` } onClick = { ( ) => changeCount ( n ) } > { n } < / button >
) ) }
< / div >
< / div >
{ view . income . length > 0 && (
< div className = "card" >
< h2 > { t ( "budget.income" ) } < / h2 >
< table className = "table" >
< tbody >
{ view . income . map ( ( i ) => (
< tr key = { i . id } >
< td > { i . label } < / td >
< td style = { { textAlign : "right" } } className = "muted" > { t ( "budget.planned" ) } : { fmt ( i . planned _cents ) } < / td >
< td style = { { textAlign : "right" } } >
< span className = { ` amount ${ i . received _cents >= i . planned _cents ? "pos" : "neg" } ` } > { fmt ( i . received _cents ) } < / span >
< / td >
< / tr >
) ) }
< tr className = "budget-total-row" >
< td > < strong > { t ( "budget.totalIncome" ) } < / strong > < / td >
< td style = { { textAlign : "right" } } className = "muted" > { fmt ( tot . planned _income ) } < / td >
< td style = { { textAlign : "right" } } > < strong > { fmt ( tot . received _income ) } < / strong > < / td >
< / tr >
< / tbody >
< / table >
< / div >
) }
< div className = "card" >
< h2 > { t ( "budget.expenses" ) } < / h2 >
{ view . groups . map ( ( g ) => (
< div className = "budget-group" key = { g . id } >
< div className = "budget-group-head" >
< IconChip name = { g . icon } color = { g . color } size = { 26 } / >
< span className = "grow" > < strong > { g . name } < / strong > < / span >
< span className = { ` amount ${ g . over ? "neg" : "muted" } ` } > { fmt ( g . actual _cents ) } < / span >
< span className = "muted" > / { fmt ( g . planned _cents ) } < / span >
< / div >
{ g . categories . map ( ( c ) => (
< div className = "budget-cat" key = { c . category _id } >
< div className = "budget-cat-name" >
< IconChip name = { c . icon } color = { c . color } size = { 24 } / >
< span > { c . name } < / span >
< / div >
< div className = "budget-cat-bar" >
< ProgressBar actual = { c . actual _cents } planned = { c . planned _cents } / >
< / div >
< span className = { ` amount budget-actual ${ c . over ? "neg" : "muted" } ` } > { fmt ( c . actual _cents ) } < / span >
< span className = "muted budget-slash" > / < / span >
< EditableAmount cents = { c . planned _cents } onSave = { ( cents ) => saveMonth ( c . category _id , cents ) } / >
< / div >
) ) }
< / div >
) ) }
< div className = "budget-total-row card-total" >
< span className = "grow" > < strong > { t ( "budget.totalExpense" ) } < / strong > < / span >
< span className = { ` amount ${ tot . actual _expense > tot . planned _expense ? "neg" : "" } ` } > { fmt ( tot . actual _expense ) } < / span >
< span className = "muted" > / { fmt ( tot . planned _expense ) } < / span >
< / div >
< div className = "budget-months" style = { { "--cols" : views . length } } >
{ views . map ( ( v ) => < MonthColumn key = { v . label } view = { v } / > ) }
< / div >
< / >
) : (