Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 86 additions & 11 deletions src/features/budget-tracker/components/budget-tracker-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'
import { type BudgetItem, type BudgetSubItem } from '@/db/schema'
import { PlusIcon } from 'lucide-react'
import { useBudgetFiltersStore } from '@/store/use-budget-filters-store'
import { Eye, PlusIcon } from 'lucide-react'
import { toast } from 'sonner'

import { formatCzechNumber } from '@/lib/formatters'
Expand All @@ -19,6 +20,7 @@ import { BalanceInput } from './balance-input'
import { BudgetItemForm } from './budget-item-form'
import { BudgetSubItemForm } from './budget-sub-item-form'
import { BudgetTrackerTable } from './budget-tracker-table'
import { NextMonthProjection } from './next-month-projection'

export function BudgetTrackerList() {
const { data: balance } = useBudgetBalance()
Expand Down Expand Up @@ -47,14 +49,23 @@ export function BudgetTrackerList() {
// Selected items for filtering summary calculations
const [selectedItemIds, setSelectedItemIds] = useState<Set<string>>(new Set())

// Hidden items for visibility filtering (persisted via Zustand)
const hiddenItemIds = useBudgetFiltersStore((state) => state.hiddenItemIds)
const toggleHideItemStore = useBudgetFiltersStore((state) => state.toggleHideItem)
const showAllStore = useBudgetFiltersStore((state) => state.showAll)

if (isLoading) {
return <div className="p-4">Loading budget tracker...</div>
}

const currentBalance = balance ? parseFloat(balance.amount) : 0

// Filter out hidden items first
const visibleItems = items.filter((item) => !hiddenItemIds.has(item.id))

// Filter items based on selection (if any items are selected, only show those)
const filteredItems = selectedItemIds.size > 0 ? items.filter((item) => selectedItemIds.has(item.id)) : items
const filteredItems =
selectedItemIds.size > 0 ? visibleItems.filter((item) => selectedItemIds.has(item.id)) : visibleItems

// Calculate total amount and total paid using effective amounts (includes sub-items)
const totalAmount = filteredItems.reduce((sum, item) => {
Expand Down Expand Up @@ -87,13 +98,29 @@ export function BudgetTrackerList() {
}

const handleSelectAll = () => {
if (selectedItemIds.size === items.length) {
if (selectedItemIds.size === visibleItems.length) {
setSelectedItemIds(new Set())
} else {
setSelectedItemIds(new Set(items.map((item) => item.id)))
setSelectedItemIds(new Set(visibleItems.map((item) => item.id)))
}
}

const handleToggleHideItem = (itemId: string) => {
toggleHideItemStore(itemId)
// Also remove from selected if hiding
if (!hiddenItemIds.has(itemId)) {
setSelectedItemIds((selectedPrev) => {
const newSelected = new Set(selectedPrev)
newSelected.delete(itemId)
return newSelected
})
}
}

const handleShowAll = () => {
showAllStore()
}

const handleEditItem = (item: BudgetItem) => {
setEditingItem(item)
}
Expand Down Expand Up @@ -155,19 +182,30 @@ export function BudgetTrackerList() {
return (
<div className="space-y-6">
{/* Balance Section */}
<BalanceInput />
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<BalanceInput />
<NextMonthProjection currentBalance={currentBalance} />
</div>

{/* Summary Section */}
<div>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-xl font-semibold">
Summary {selectedItemIds.size > 0 && `(${selectedItemIds.size} selected)`}
{hiddenItemIds.size > 0 && ` • ${hiddenItemIds.size} hidden`}
</h2>
{items.length > 0 && (
<Button variant="outline" onClick={handleSelectAll}>
{selectedItemIds.size === items.length ? 'Deselect All' : 'Select All'}
</Button>
)}
<div className="flex gap-2">
{hiddenItemIds.size > 0 && (
<Button variant="outline" onClick={handleShowAll}>
Show All ({items.length})
</Button>
)}
{visibleItems.length > 0 && (
<Button variant="outline" onClick={handleSelectAll}>
{selectedItemIds.size === visibleItems.length ? 'Deselect All' : 'Select All'}
</Button>
)}
</div>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div className="grid gap-1 rounded-md border p-4">
Expand Down Expand Up @@ -196,10 +234,11 @@ export function BudgetTrackerList() {
{/* Budget Items Table */}
{items.length > 0 ? (
<BudgetTrackerTable
data={items}
data={visibleItems}
balance={currentBalance}
selectedItemIds={selectedItemIds}
onToggleSelectItem={handleToggleSelectItem}
onToggleHideItem={handleToggleHideItem}
onEditItem={handleEditItem}
onDeleteItem={handleDeleteItem}
onAddSubItem={handleAddSubItem}
Expand All @@ -217,6 +256,42 @@ export function BudgetTrackerList() {
</div>
)}

{/* Hidden Items Section */}
{hiddenItemIds.size > 0 && (
<div className="space-y-4">
<h3 className="text-lg font-semibold">Hidden Items ({hiddenItemIds.size})</h3>
<div className="rounded-md border">
<div className="divide-y">
{items
.filter((item) => hiddenItemIds.has(item.id))
.map((item) => (
<div key={item.id} className="flex items-center justify-between p-4">
<div className="flex items-center gap-4">
<span className="font-medium">{item.name}</span>
<span className="text-muted-foreground text-sm">
{formatCzechNumber(
(item as typeof item & { _effectiveAmount?: number })._effectiveAmount ||
parseFloat(item.amount)
)}{' '}
CZK
</span>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => handleToggleHideItem(item.id)}
aria-label={`Show ${item.name}`}
>
<Eye className="h-4 w-4" />
</Button>
</div>
))}
</div>
</div>
</div>
)}

{/* Add/Edit Item Dialog */}
<BudgetItemForm open={isAddItemDialogOpen} onOpenChange={setIsAddItemDialogOpen} />

Expand Down
29 changes: 25 additions & 4 deletions src/features/budget-tracker/components/budget-tracker-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { type BudgetItem, type BudgetSubItem } from '@/db/schema'
import { ChevronDown, ChevronRight, Pencil, Plus, Trash2, TriangleAlert } from 'lucide-react'
import { ChevronDown, ChevronRight, EyeOff, Pencil, Plus, Trash2, TriangleAlert } from 'lucide-react'

import { formatCzechNumber } from '@/lib/formatters'
import { cn } from '@/lib/utils'
Expand All @@ -14,6 +14,7 @@ interface BudgetTrackerTableProps {
balance: number
selectedItemIds: Set<string>
onToggleSelectItem: (itemId: string) => void
onToggleHideItem: (itemId: string) => void
onEditItem: (item: BudgetItem) => void
onDeleteItem: (item: BudgetItem) => void
onAddSubItem: (itemId: string) => void
Expand All @@ -27,6 +28,7 @@ export function BudgetTrackerTable({
balance,
selectedItemIds,
onToggleSelectItem,
onToggleHideItem,
onEditItem,
onDeleteItem,
onAddSubItem,
Expand Down Expand Up @@ -80,7 +82,7 @@ export function BudgetTrackerTable({
<TableHead>Amount Paid</TableHead>
<TableHead className="w-[80px]">Paid</TableHead>
<TableHead>Note</TableHead>
<TableHead className="w-[140px]">Actions</TableHead>
<TableHead className="w-[180px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
Expand Down Expand Up @@ -156,7 +158,12 @@ export function BudgetTrackerTable({
<TableCell className="font-medium">{item.name}</TableCell>
<TableCell>
<div className="flex items-center gap-2">
{formatCzechNumber(effectiveAmount)} CZK
<span>{formatCzechNumber(effectiveAmount)} CZK</span>
{parseFloat(effectiveAmountPaid) > 0 && !item.paid && (
<span className="text-muted-foreground text-sm">
({formatCzechNumber(parseFloat(effectiveAmount) - parseFloat(effectiveAmountPaid))} CZK)
</span>
)}
{!affordable && !item.paid && <TriangleAlert className="text-destructive size-3 flex-shrink-0" />}
</div>
</TableCell>
Expand All @@ -173,6 +180,15 @@ export function BudgetTrackerTable({
</TableCell>
<TableCell>
<div className="flex gap-2">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => onToggleHideItem(item.id)}
aria-label="Hide item"
>
<EyeOff className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
Expand Down Expand Up @@ -217,7 +233,12 @@ export function BudgetTrackerTable({
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
{formatCzechNumber(subItem.amount)} CZK
<span>{formatCzechNumber(subItem.amount)} CZK</span>
{parseFloat(subItem.amountPaid) > 0 && !subItem.paid && (
<span className="text-muted-foreground text-sm">
({formatCzechNumber(parseFloat(subItem.amount) - parseFloat(subItem.amountPaid))} CZK)
</span>
)}
{!subItemAffordable && !subItem.paid && (
<TriangleAlert className="text-destructive size-3 flex-shrink-0" />
)}
Expand Down
96 changes: 96 additions & 0 deletions src/features/budget-tracker/components/next-month-projection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { PlusIcon, XIcon } from 'lucide-react'

import { formatCzechNumber } from '@/lib/formatters'

interface NextMonthProjectionProps {
currentBalance: number
}

export function NextMonthProjection({ currentBalance }: NextMonthProjectionProps) {
const [additionalAmount, setAdditionalAmount] = useState<string>('')
const [isAdding, setIsAdding] = useState(false)

const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()

if (!additionalAmount.trim() || isNaN(parseFloat(additionalAmount))) {
return
}

setIsAdding(false)
}

const handleCancel = () => {
setIsAdding(false)
}

const handleClear = () => {
setAdditionalAmount('')
}

const projectedBalance =
additionalAmount && !isNaN(parseFloat(additionalAmount))
? currentBalance + parseFloat(additionalAmount)
: currentBalance

const hasProjection = additionalAmount && !isNaN(parseFloat(additionalAmount))

return (
<div className="rounded-md border p-6">
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label>Next Month Projection</Label>
{hasProjection && !isAdding && (
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={handleClear} aria-label="Clear projection">
<XIcon className="h-4 w-4" />
</Button>
)}
</div>

{isAdding ? (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="additionalAmount" className="text-sm">
Additional Amount
</Label>
<Input
id="additionalAmount"
type="text"
value={additionalAmount}
onChange={(e) => setAdditionalAmount(e.target.value)}
placeholder="60000"
/>
</div>
<div className="flex gap-2">
<Button type="submit" className="flex-1">
Apply
</Button>
<Button type="button" variant="outline" onClick={handleCancel}>
Cancel
</Button>
</div>
</form>
) : hasProjection ? (
<div className="space-y-2">
<div className="text-3xl font-bold">{formatCzechNumber(projectedBalance)} CZK</div>
<div className="text-muted-foreground text-sm">
Current: {formatCzechNumber(currentBalance)} CZK + {formatCzechNumber(additionalAmount)} CZK
</div>
<Button variant="outline" onClick={() => setIsAdding(true)} className="w-full">
Update Projection
</Button>
</div>
) : (
<Button variant="outline" onClick={() => setIsAdding(true)} className="w-full">
<PlusIcon className="h-4 w-4" />
Add Projection
</Button>
)}
</div>
</div>
)
}
53 changes: 53 additions & 0 deletions src/store/use-budget-filters-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'

interface BudgetFiltersState {
hiddenItemIds: Set<string>
toggleHideItem: (id: string) => void
showAll: () => void
}

export const useBudgetFiltersStore = create<BudgetFiltersState>()(
persist(
(set) => ({
hiddenItemIds: new Set(),
toggleHideItem: (id) =>
set((state) => {
const newSet = new Set(state.hiddenItemIds)
if (newSet.has(id)) {
newSet.delete(id)
} else {
newSet.add(id)
}
return { hiddenItemIds: newSet }
}),
showAll: () => set({ hiddenItemIds: new Set() }),
}),
{
name: 'budget-filters',
storage: {
getItem: (name) => {
const str = localStorage.getItem(name)
if (!str) return null
const { state } = JSON.parse(str)
return {
state: {
...state,
hiddenItemIds: new Set(state.hiddenItemIds || []),
},
}
},
setItem: (name, newValue) => {
const str = JSON.stringify({
state: {
...newValue.state,
hiddenItemIds: Array.from(newValue.state.hiddenItemIds),
},
})
localStorage.setItem(name, str)
},
removeItem: (name) => localStorage.removeItem(name),
},
}
)
)