feat: Модуль Финансы + Трекер + CI/CD #1
30
src/App.jsx
30
src/App.jsx
@@ -13,6 +13,7 @@ import ForgotPassword from "./pages/ForgotPassword"
|
|||||||
import Stats from "./pages/Stats"
|
import Stats from "./pages/Stats"
|
||||||
import Settings from "./pages/Settings"
|
import Settings from "./pages/Settings"
|
||||||
import Finance from "./pages/Finance"
|
import Finance from "./pages/Finance"
|
||||||
|
import Tracker from "./pages/Tracker"
|
||||||
|
|
||||||
function ProtectedRoute({ children }) {
|
function ProtectedRoute({ children }) {
|
||||||
const { isAuthenticated, isLoading } = useAuthStore()
|
const { isAuthenticated, isLoading } = useAuthStore()
|
||||||
@@ -93,11 +94,20 @@ export default function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/tracker"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Tracker />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{/* Legacy routes redirect to tracker */}
|
||||||
<Route
|
<Route
|
||||||
path="/habits"
|
path="/habits"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<Habits />
|
<Navigate to="/tracker" replace />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -105,7 +115,15 @@ export default function App() {
|
|||||||
path="/tasks"
|
path="/tasks"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<Tasks />
|
<Navigate to="/tracker" replace />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/stats"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Navigate to="/tracker" replace />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -117,14 +135,6 @@ export default function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
|
||||||
path="/stats"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute>
|
|
||||||
<Stats />
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
<Route
|
||||||
path="/finance"
|
path="/finance"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
import { NavLink } from "react-router-dom"
|
import { NavLink } from "react-router-dom"
|
||||||
import { Home, ListChecks, CheckSquare, BarChart3, PiggyBank, Wallet, Settings } from "lucide-react"
|
import { Home, BarChart3, Wallet, Settings } from "lucide-react"
|
||||||
import clsx from "clsx"
|
import clsx from "clsx"
|
||||||
|
|
||||||
export default function Navigation() {
|
export default function Navigation() {
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ to: "/", icon: Home, label: "Сегодня" },
|
{ to: "/", icon: Home, label: "Главная" },
|
||||||
{ to: "/habits", icon: ListChecks, label: "Привычки" },
|
{ to: "/tracker", icon: BarChart3, label: "Трекер" },
|
||||||
{ to: "/tasks", icon: CheckSquare, label: "Задачи" },
|
|
||||||
{ to: "/stats", icon: BarChart3, label: "Статистика" },
|
|
||||||
{ to: "/savings", icon: PiggyBank, label: "Накопления" },
|
|
||||||
{ to: "/finance", icon: Wallet, label: "Финансы" },
|
{ to: "/finance", icon: Wallet, label: "Финансы" },
|
||||||
{ to: "/settings", icon: Settings, label: "Настройки" },
|
{ to: "/settings", icon: Settings, label: "Настройки" },
|
||||||
]
|
]
|
||||||
@@ -21,17 +18,18 @@ export default function Navigation() {
|
|||||||
<NavLink
|
<NavLink
|
||||||
key={to}
|
key={to}
|
||||||
to={to}
|
to={to}
|
||||||
|
end={to === "/"}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
clsx(
|
clsx(
|
||||||
"flex flex-col items-center gap-0.5 px-1.5 py-1.5 rounded-xl transition-all",
|
"flex flex-col items-center gap-0.5 px-3 py-2 rounded-xl transition-all",
|
||||||
isActive
|
isActive
|
||||||
? "text-primary-600 dark:text-primary-400 bg-primary-50 dark:bg-primary-900/30"
|
? "text-primary-600 dark:text-primary-400 bg-primary-50 dark:bg-primary-900/30"
|
||||||
: "text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300"
|
: "text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Icon size={18} />
|
<Icon size={22} />
|
||||||
<span className="text-[10px] font-medium">{label}</span>
|
<span className="text-[11px] font-medium">{label}</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -59,128 +59,138 @@ export default function AddTransactionModal({ onClose, onSaved }) {
|
|||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="bg-white dark:bg-gray-900 rounded-t-3xl w-full max-w-lg p-6 max-h-[90vh] overflow-y-auto"
|
className="bg-white dark:bg-gray-900 rounded-t-3xl w-full max-w-lg flex flex-col"
|
||||||
|
style={{ maxHeight: "85vh" }}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className="w-12 h-1 bg-gray-300 dark:bg-gray-700 rounded-full mx-auto mb-4" />
|
{/* Header - fixed */}
|
||||||
<h2 className="text-lg font-display font-bold text-gray-900 dark:text-white mb-4">
|
<div className="px-6 pt-4 pb-2 flex-shrink-0">
|
||||||
Новая запись
|
<div className="w-12 h-1 bg-gray-300 dark:bg-gray-700 rounded-full mx-auto mb-4" />
|
||||||
</h2>
|
<h2 className="text-lg font-display font-bold text-gray-900 dark:text-white">
|
||||||
|
Новая запись
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-5">
|
{/* Scrollable content */}
|
||||||
{/* Type toggle */}
|
<div className="flex-1 overflow-y-auto px-6 pb-4" style={{ WebkitOverflowScrolling: "touch" }}>
|
||||||
<div className="flex bg-gray-100 dark:bg-gray-800 rounded-xl p-1">
|
<div className="space-y-5 pt-2">
|
||||||
{[
|
{/* Type toggle */}
|
||||||
["expense", "Расход"],
|
<div className="flex bg-gray-100 dark:bg-gray-800 rounded-xl p-1">
|
||||||
["income", "Доход"],
|
{[
|
||||||
].map(([k, l]) => (
|
["expense", "Расход"],
|
||||||
<button
|
["income", "Доход"],
|
||||||
key={k}
|
].map(([k, l]) => (
|
||||||
onClick={() => {
|
<button
|
||||||
setType(k)
|
key={k}
|
||||||
setCategoryId(null)
|
onClick={() => {
|
||||||
}}
|
setType(k)
|
||||||
className={`flex-1 py-2.5 rounded-lg text-sm font-semibold transition ${
|
setCategoryId(null)
|
||||||
type === k
|
}}
|
||||||
? k === "expense"
|
className={`flex-1 py-2.5 rounded-lg text-sm font-semibold transition ${
|
||||||
? "bg-red-500 text-white"
|
type === k
|
||||||
: "bg-green-500 text-white"
|
? k === "expense"
|
||||||
: "text-gray-500"
|
? "bg-red-500 text-white"
|
||||||
}`}
|
: "bg-green-500 text-white"
|
||||||
>
|
: "text-gray-500"
|
||||||
{l}
|
}`}
|
||||||
</button>
|
>
|
||||||
))}
|
{l}
|
||||||
</div>
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Quick templates */}
|
{/* Quick templates */}
|
||||||
{type === "expense" && (
|
{type === "expense" && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase mb-2">
|
||||||
|
Быстрые шаблоны
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{quickTemplates.map((t, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => applyTemplate(t)}
|
||||||
|
className="px-3 py-1.5 rounded-lg bg-gray-100 dark:bg-gray-800 text-sm text-gray-700 dark:text-gray-300 hover:bg-primary-100 dark:hover:bg-primary-900 transition"
|
||||||
|
>
|
||||||
|
{t.description}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Amount */}
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase mb-2">
|
<label className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase block mb-1">
|
||||||
Быстрые шаблоны
|
Сумма
|
||||||
</p>
|
</label>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="relative">
|
||||||
{quickTemplates.map((t, i) => (
|
<input
|
||||||
|
type="number"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={amount}
|
||||||
|
onChange={(e) => setAmount(e.target.value)}
|
||||||
|
className="w-full px-4 py-3 rounded-xl bg-gray-100 dark:bg-gray-800 text-2xl font-bold text-gray-900 dark:text-white outline-none"
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
<span className="absolute right-4 top-1/2 -translate-y-1/2 text-lg text-gray-400">
|
||||||
|
₽
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase block mb-1">
|
||||||
|
Описание
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
className="w-full px-4 py-3 rounded-xl bg-gray-100 dark:bg-gray-800 text-sm text-gray-900 dark:text-white outline-none"
|
||||||
|
placeholder="Что купили?"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category */}
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase block mb-1">
|
||||||
|
Категория
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{cats.map((c) => (
|
||||||
<button
|
<button
|
||||||
key={i}
|
key={c.id}
|
||||||
onClick={() => applyTemplate(t)}
|
onClick={() => setCategoryId(c.id)}
|
||||||
className="px-3 py-1.5 rounded-lg bg-gray-100 dark:bg-gray-800 text-sm text-gray-700 dark:text-gray-300 hover:bg-primary-100 dark:hover:bg-primary-900 transition"
|
className={`px-3 py-2.5 rounded-xl text-sm text-left font-medium transition ${
|
||||||
|
categoryId === c.id
|
||||||
|
? "bg-primary-500 text-white ring-2 ring-primary-300"
|
||||||
|
: "bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{t.description}
|
{c.emoji} {c.name}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Amount */}
|
{/* Date */}
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase block mb-1">
|
<label className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase block mb-1">
|
||||||
Сумма
|
Дата
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="date"
|
||||||
value={amount}
|
value={date}
|
||||||
onChange={(e) => setAmount(e.target.value)}
|
onChange={(e) => setDate(e.target.value)}
|
||||||
className="w-full px-4 py-3 rounded-xl bg-gray-100 dark:bg-gray-800 text-2xl font-bold text-gray-900 dark:text-white outline-none"
|
className="w-full px-4 py-3 rounded-xl bg-gray-100 dark:bg-gray-800 text-sm text-gray-900 dark:text-white outline-none"
|
||||||
placeholder="0"
|
|
||||||
/>
|
/>
|
||||||
<span className="absolute right-4 top-1/2 -translate-y-1/2 text-lg text-gray-400">
|
|
||||||
₽
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Description */}
|
{/* Sticky submit button */}
|
||||||
<div>
|
<div className="px-6 py-4 border-t border-gray-100 dark:border-gray-800 flex-shrink-0 bg-white dark:bg-gray-900">
|
||||||
<label className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase block mb-1">
|
|
||||||
Описание
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
value={description}
|
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
|
||||||
className="w-full px-4 py-3 rounded-xl bg-gray-100 dark:bg-gray-800 text-sm text-gray-900 dark:text-white outline-none"
|
|
||||||
placeholder="Что купили?"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Category */}
|
|
||||||
<div>
|
|
||||||
<label className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase block mb-1">
|
|
||||||
Категория
|
|
||||||
</label>
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
{cats.map((c) => (
|
|
||||||
<button
|
|
||||||
key={c.id}
|
|
||||||
onClick={() => setCategoryId(c.id)}
|
|
||||||
className={`px-3 py-2.5 rounded-xl text-sm text-left font-medium transition ${
|
|
||||||
categoryId === c.id
|
|
||||||
? "bg-primary-500 text-white ring-2 ring-primary-300"
|
|
||||||
: "bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{c.emoji} {c.name}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Date */}
|
|
||||||
<div>
|
|
||||||
<label className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase block mb-1">
|
|
||||||
Дата
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={date}
|
|
||||||
onChange={(e) => setDate(e.target.value)}
|
|
||||||
className="w-full px-4 py-3 rounded-xl bg-gray-100 dark:bg-gray-800 text-sm text-gray-900 dark:text-white outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Submit */}
|
|
||||||
<button
|
<button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={saving || !amount || !categoryId}
|
disabled={saving || !amount || !categoryId}
|
||||||
|
|||||||
238
src/components/finance/CategoriesManager.jsx
Normal file
238
src/components/finance/CategoriesManager.jsx
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
import { useState, useEffect } from "react"
|
||||||
|
import { financeApi } from "../../api/finance"
|
||||||
|
|
||||||
|
const EMOJI_OPTIONS = ["🏠","🍔","🚗","👕","🏥","🎮","📱","✈️","🎁","🛒","🛍️","💎","📦","💰","💼","📈","🎓","🏋️","🐶","🎵","💊","🏪","☕","🍕","🎬","📚","🔧","💡","🌐","🎯"]
|
||||||
|
|
||||||
|
const fmt = (n) => n?.toLocaleString("ru-RU") + " ₽"
|
||||||
|
|
||||||
|
export default function CategoriesManager({ refreshKey }) {
|
||||||
|
const [categories, setCategories] = useState([])
|
||||||
|
const [tab, setTab] = useState("expense")
|
||||||
|
const [showModal, setShowModal] = useState(false)
|
||||||
|
const [editing, setEditing] = useState(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
// Form state
|
||||||
|
const [emoji, setEmoji] = useState("📦")
|
||||||
|
const [name, setName] = useState("")
|
||||||
|
const [type, setType] = useState("expense")
|
||||||
|
const [budget, setBudget] = useState("")
|
||||||
|
const [showEmojiPicker, setShowEmojiPicker] = useState(false)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [deleting, setDeleting] = useState(null)
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
const cats = await financeApi.listCategories()
|
||||||
|
setCategories(cats)
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { load() }, [refreshKey])
|
||||||
|
|
||||||
|
const cats = categories.filter(c => c.type === tab)
|
||||||
|
|
||||||
|
const openAdd = () => {
|
||||||
|
setEditing(null)
|
||||||
|
setEmoji("📦")
|
||||||
|
setName("")
|
||||||
|
setType(tab)
|
||||||
|
setBudget("")
|
||||||
|
setShowModal(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openEdit = (cat) => {
|
||||||
|
setEditing(cat)
|
||||||
|
setEmoji(cat.emoji || "📦")
|
||||||
|
setName(cat.name)
|
||||||
|
setType(cat.type)
|
||||||
|
setBudget(cat.budget ? String(cat.budget) : "")
|
||||||
|
setShowModal(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!name.trim()) return
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const data = { name: name.trim(), emoji, type, budget: budget ? parseFloat(budget) : 0 }
|
||||||
|
if (editing) {
|
||||||
|
await financeApi.updateCategory(editing.id, data)
|
||||||
|
} else {
|
||||||
|
await financeApi.createCategory(data)
|
||||||
|
}
|
||||||
|
setShowModal(false)
|
||||||
|
load()
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
alert("Ошибка при сохранении")
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (id) => {
|
||||||
|
if (!confirm("Удалить категорию? Транзакции с ней останутся.")) return
|
||||||
|
setDeleting(id)
|
||||||
|
try {
|
||||||
|
await financeApi.deleteCategory(id)
|
||||||
|
load()
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
alert("Ошибка при удалении")
|
||||||
|
} finally {
|
||||||
|
setDeleting(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[1,2,3].map(i => (
|
||||||
|
<div key={i} className="card p-4 animate-pulse">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-11 h-11 rounded-xl bg-gray-200 dark:bg-gray-700" />
|
||||||
|
<div className="flex-1"><div className="h-5 bg-gray-200 dark:bg-gray-700 rounded w-1/2" /></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Sub-tabs: expense / income */}
|
||||||
|
<div className="flex gap-2 mb-4">
|
||||||
|
{[["expense","Расходы"],["income","Доходы"]].map(([k,l]) => (
|
||||||
|
<button key={k} onClick={() => setTab(k)}
|
||||||
|
className={`px-4 py-2 rounded-xl text-sm font-semibold transition ${
|
||||||
|
tab === k ? "bg-primary-500 text-white" : "bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400"
|
||||||
|
}`}>
|
||||||
|
{l}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<div className="flex-1" />
|
||||||
|
<button onClick={openAdd} className="px-4 py-2 bg-primary-500 text-white rounded-xl text-sm font-medium hover:bg-primary-600 transition">
|
||||||
|
+ Добавить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category list */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{cats.length === 0 ? (
|
||||||
|
<div className="card p-8 text-center">
|
||||||
|
<p className="text-gray-500 dark:text-gray-400">Нет категорий</p>
|
||||||
|
<button onClick={openAdd} className="mt-3 text-sm text-primary-600 dark:text-primary-400 font-medium">+ Добавить</button>
|
||||||
|
</div>
|
||||||
|
) : cats.map(c => {
|
||||||
|
const pct = c.budget > 0 ? Math.min(Math.round((c.spent || 0) / c.budget * 100), 100) : 0
|
||||||
|
const isOver = c.budget > 0 && (c.spent || 0) > c.budget * 0.9
|
||||||
|
return (
|
||||||
|
<div key={c.id} className="card p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-11 h-11 rounded-xl bg-gray-100 dark:bg-gray-800 flex items-center justify-center text-2xl flex-shrink-0">
|
||||||
|
{c.emoji}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="font-medium text-gray-900 dark:text-white truncate">{c.name}</span>
|
||||||
|
{c.budget > 0 && (
|
||||||
|
<span className={`text-xs font-semibold ml-2 whitespace-nowrap ${isOver ? "text-red-500" : "text-gray-500 dark:text-gray-400"}`}>
|
||||||
|
{fmt(c.spent || 0)} / {fmt(c.budget)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{c.budget > 0 && (
|
||||||
|
<div className="mt-2 h-2 bg-gray-100 dark:bg-gray-800 rounded-full overflow-hidden">
|
||||||
|
<div className={`h-full rounded-full transition-all ${isOver ? "bg-red-500" : pct > 70 ? "bg-yellow-500" : "bg-primary-500"}`}
|
||||||
|
style={{ width: pct + "%" }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 flex-shrink-0">
|
||||||
|
<button onClick={() => openEdit(c)} className="p-2 text-gray-400 hover:text-primary-500 rounded-lg transition">
|
||||||
|
✏️
|
||||||
|
</button>
|
||||||
|
<button onClick={() => handleDelete(c.id)} disabled={deleting === c.id}
|
||||||
|
className="p-2 text-gray-400 hover:text-red-500 rounded-lg transition disabled:opacity-50">
|
||||||
|
{deleting === c.id ? "⏳" : "🗑️"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add/Edit Modal */}
|
||||||
|
{showModal && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-end justify-center z-50" onClick={() => setShowModal(false)}>
|
||||||
|
<div className="bg-white dark:bg-gray-900 rounded-t-3xl w-full max-w-lg p-6" onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="w-12 h-1 bg-gray-300 dark:bg-gray-700 rounded-full mx-auto mb-4" />
|
||||||
|
<h2 className="text-lg font-display font-bold text-gray-900 dark:text-white mb-4">
|
||||||
|
{editing ? "Редактировать категорию" : "Новая категория"}
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Emoji + Name */}
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button onClick={() => setShowEmojiPicker(!showEmojiPicker)}
|
||||||
|
className="w-14 h-14 rounded-xl bg-gray-100 dark:bg-gray-800 text-3xl flex items-center justify-center hover:bg-gray-200 dark:hover:bg-gray-700 transition flex-shrink-0">
|
||||||
|
{emoji}
|
||||||
|
</button>
|
||||||
|
<input value={name} onChange={e => setName(e.target.value)}
|
||||||
|
className="flex-1 px-4 py-3 rounded-xl bg-gray-100 dark:bg-gray-800 text-sm text-gray-900 dark:text-white outline-none"
|
||||||
|
placeholder="Название категории" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Emoji picker */}
|
||||||
|
{showEmojiPicker && (
|
||||||
|
<div className="grid grid-cols-8 gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-xl">
|
||||||
|
{EMOJI_OPTIONS.map(e => (
|
||||||
|
<button key={e} onClick={() => { setEmoji(e); setShowEmojiPicker(false) }}
|
||||||
|
className={`text-2xl p-1 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 transition ${emoji === e ? "bg-primary-100 dark:bg-primary-900" : ""}`}>
|
||||||
|
{e}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Type toggle */}
|
||||||
|
<div className="flex bg-gray-100 dark:bg-gray-800 rounded-xl p-1">
|
||||||
|
{[["expense","Расход"],["income","Доход"]].map(([k,l]) => (
|
||||||
|
<button key={k} onClick={() => setType(k)}
|
||||||
|
className={`flex-1 py-2.5 rounded-lg text-sm font-semibold transition ${
|
||||||
|
type === k ? "bg-primary-500 text-white" : "text-gray-500"
|
||||||
|
}`}>
|
||||||
|
{l}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Budget */}
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase block mb-1">Бюджет (опц.)</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input type="number" value={budget} onChange={e => setBudget(e.target.value)}
|
||||||
|
className="w-full px-4 py-3 rounded-xl bg-gray-100 dark:bg-gray-800 text-sm text-gray-900 dark:text-white outline-none"
|
||||||
|
placeholder="0" />
|
||||||
|
<span className="absolute right-4 top-1/2 -translate-y-1/2 text-gray-400">₽</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Save */}
|
||||||
|
<button onClick={handleSave} disabled={saving || !name.trim()}
|
||||||
|
className="w-full py-3.5 bg-primary-500 hover:bg-primary-600 disabled:opacity-50 text-white rounded-xl font-semibold transition">
|
||||||
|
{saving ? "Сохраняю..." : editing ? "Сохранить" : "Создать"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,12 +3,14 @@ import Navigation from "../components/Navigation"
|
|||||||
import FinanceDashboard from "../components/finance/FinanceDashboard"
|
import FinanceDashboard from "../components/finance/FinanceDashboard"
|
||||||
import TransactionList from "../components/finance/TransactionList"
|
import TransactionList from "../components/finance/TransactionList"
|
||||||
import FinanceAnalytics from "../components/finance/FinanceAnalytics"
|
import FinanceAnalytics from "../components/finance/FinanceAnalytics"
|
||||||
|
import CategoriesManager from "../components/finance/CategoriesManager"
|
||||||
import AddTransactionModal from "../components/finance/AddTransactionModal"
|
import AddTransactionModal from "../components/finance/AddTransactionModal"
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ key: "dashboard", label: "Обзор", icon: "📊" },
|
{ key: "dashboard", label: "Обзор", icon: "📊" },
|
||||||
{ key: "transactions", label: "Транзакции", icon: "📋" },
|
{ key: "transactions", label: "Транзакции", icon: "📋" },
|
||||||
{ key: "analytics", label: "Аналитика", icon: "📈" },
|
{ key: "analytics", label: "Аналитика", icon: "📈" },
|
||||||
|
{ key: "categories", label: "Категории", icon: "🏷️" },
|
||||||
]
|
]
|
||||||
|
|
||||||
export default function Finance() {
|
export default function Finance() {
|
||||||
@@ -34,12 +36,12 @@ export default function Finance() {
|
|||||||
+
|
+
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="max-w-lg mx-auto px-4 pb-3 flex gap-2">
|
<div className="max-w-lg mx-auto px-4 pb-3 flex gap-1.5 overflow-x-auto scrollbar-hide">
|
||||||
{tabs.map((t) => (
|
{tabs.map((t) => (
|
||||||
<button
|
<button
|
||||||
key={t.key}
|
key={t.key}
|
||||||
onClick={() => setActiveTab(t.key)}
|
onClick={() => setActiveTab(t.key)}
|
||||||
className={`flex-1 py-2 rounded-xl text-sm font-semibold transition ${
|
className={`flex-1 min-w-0 py-2 rounded-xl text-xs sm:text-sm font-semibold transition whitespace-nowrap px-2 ${
|
||||||
activeTab === t.key
|
activeTab === t.key
|
||||||
? "bg-primary-500 text-white"
|
? "bg-primary-500 text-white"
|
||||||
: "bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400"
|
: "bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400"
|
||||||
@@ -57,6 +59,7 @@ export default function Finance() {
|
|||||||
<TransactionList key={refreshKey} onAdd={() => setShowAdd(true)} />
|
<TransactionList key={refreshKey} onAdd={() => setShowAdd(true)} />
|
||||||
)}
|
)}
|
||||||
{activeTab === "analytics" && <FinanceAnalytics key={refreshKey} />}
|
{activeTab === "analytics" && <FinanceAnalytics key={refreshKey} />}
|
||||||
|
{activeTab === "categories" && <CategoriesManager refreshKey={refreshKey} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showAdd && (
|
{showAdd && (
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import EditHabitModal from '../components/EditHabitModal'
|
|||||||
import Navigation from '../components/Navigation'
|
import Navigation from '../components/Navigation'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
|
||||||
export default function Habits() {
|
export default function Habits({ embedded = false }) {
|
||||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||||
const [editingHabit, setEditingHabit] = useState(null)
|
const [editingHabit, setEditingHabit] = useState(null)
|
||||||
const [showArchived, setShowArchived] = useState(false)
|
const [showArchived, setShowArchived] = useState(false)
|
||||||
@@ -67,8 +67,8 @@ export default function Habits() {
|
|||||||
const archivedList = habits.filter(h => h.is_archived)
|
const archivedList = habits.filter(h => h.is_archived)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-surface-50 dark:bg-gray-950 gradient-mesh pb-24 transition-colors duration-300">
|
<div className={embedded ? "" : "min-h-screen bg-surface-50 dark:bg-gray-950 gradient-mesh pb-24 transition-colors duration-300"}>
|
||||||
<header className="bg-white/70 dark:bg-gray-900/70 backdrop-blur-xl border-b border-gray-100/50 dark:border-gray-800 sticky top-0 z-10">
|
{!embedded && <header className="bg-white/70 dark:bg-gray-900/70 backdrop-blur-xl border-b border-gray-100/50 dark:border-gray-800 sticky top-0 z-10">
|
||||||
<div className="max-w-lg mx-auto px-4 py-4 flex items-center justify-between">
|
<div className="max-w-lg mx-auto px-4 py-4 flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-display font-bold text-gray-900 dark:text-white">Мои привычки</h1>
|
<h1 className="text-xl font-display font-bold text-gray-900 dark:text-white">Мои привычки</h1>
|
||||||
@@ -79,7 +79,7 @@ export default function Habits() {
|
|||||||
Новая
|
Новая
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>}
|
||||||
|
|
||||||
<main className="max-w-lg mx-auto px-4 py-6 space-y-6">
|
<main className="max-w-lg mx-auto px-4 py-6 space-y-6">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
@@ -168,7 +168,7 @@ export default function Habits() {
|
|||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<Navigation />
|
{!embedded && <Navigation />}
|
||||||
<CreateHabitModal open={showCreateModal} onClose={() => setShowCreateModal(false)} />
|
<CreateHabitModal open={showCreateModal} onClose={() => setShowCreateModal(false)} />
|
||||||
<EditHabitModal open={!!editingHabit} onClose={() => setEditingHabit(null)} habit={editingHabit} />
|
<EditHabitModal open={!!editingHabit} onClose={() => setEditingHabit(null)} habit={editingHabit} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { format, startOfWeek, differenceInDays, parseISO, isToday, isTomorrow, i
|
|||||||
import { ru } from 'date-fns/locale'
|
import { ru } from 'date-fns/locale'
|
||||||
import { habitsApi } from '../api/habits'
|
import { habitsApi } from '../api/habits'
|
||||||
import { tasksApi } from '../api/tasks'
|
import { tasksApi } from '../api/tasks'
|
||||||
|
import { financeApi } from '../api/finance'
|
||||||
import { useAuthStore } from '../store/auth'
|
import { useAuthStore } from '../store/auth'
|
||||||
import Navigation from '../components/Navigation'
|
import Navigation from '../components/Navigation'
|
||||||
import CreateTaskModal from '../components/CreateTaskModal'
|
import CreateTaskModal from '../components/CreateTaskModal'
|
||||||
@@ -96,6 +97,11 @@ export default function Home() {
|
|||||||
queryFn: tasksApi.today,
|
queryFn: tasksApi.today,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
const { data: financeSummary } = useQuery({
|
||||||
|
queryKey: ["finance-summary"],
|
||||||
|
queryFn: () => financeApi.getSummary(),
|
||||||
|
})
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (habits.length > 0) {
|
if (habits.length > 0) {
|
||||||
loadTodayLogs()
|
loadTodayLogs()
|
||||||
@@ -301,6 +307,27 @@ export default function Home() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tasks */}
|
{/* Tasks */}
|
||||||
|
|
||||||
|
{/* Finance Summary */}
|
||||||
|
{financeSummary && (
|
||||||
|
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="card p-5">
|
||||||
|
<h2 className="font-semibold text-gray-900 dark:text-white mb-3">💰 Баланс</h2>
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-lg font-bold text-green-500">+{(financeSummary.total_income || 0).toLocaleString("ru-RU")} ₽</p>
|
||||||
|
<p className="text-xs text-gray-500">Доходы</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-lg font-bold text-red-500">-{(financeSummary.total_expense || 0).toLocaleString("ru-RU")} ₽</p>
|
||||||
|
<p className="text-xs text-gray-500">Расходы</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className={"text-lg font-bold " + ((financeSummary.balance || 0) >= 0 ? "text-primary-500" : "text-red-500")}>{(financeSummary.balance || 0).toLocaleString("ru-RU")} ₽</p>
|
||||||
|
<p className="text-xs text-gray-500">Баланс</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
{(activeTasks.length > 0 || !tasksLoading) && (
|
{(activeTasks.length > 0 || !tasksLoading) && (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ const SectionHeader = ({ icon: Icon, title, subtitle }) => (
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
export default function Stats() {
|
export default function Stats({ embedded = false }) {
|
||||||
const [selectedHabitId, setSelectedHabitId] = useState(null)
|
const [selectedHabitId, setSelectedHabitId] = useState(null)
|
||||||
const [allHabitLogs, setAllHabitLogs] = useState({})
|
const [allHabitLogs, setAllHabitLogs] = useState({})
|
||||||
const [allHabitStats, setAllHabitStats] = useState({})
|
const [allHabitStats, setAllHabitStats] = useState({})
|
||||||
@@ -356,7 +356,7 @@ export default function Stats() {
|
|||||||
<div className="absolute bottom-1/4 right-0 w-80 h-80 bg-teal-500/5 rounded-full blur-3xl" />
|
<div className="absolute bottom-1/4 right-0 w-80 h-80 bg-teal-500/5 rounded-full blur-3xl" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<header className="relative bg-gray-900/50 backdrop-blur-xl border-b border-gray-800/50 sticky top-0 z-20">
|
{!embedded && <header className="relative bg-gray-900/50 backdrop-blur-xl border-b border-gray-800/50 sticky top-0 z-20">
|
||||||
<div className="max-w-lg mx-auto px-5 py-5">
|
<div className="max-w-lg mx-auto px-5 py-5">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-11 h-11 rounded-2xl bg-gradient-to-br from-primary-500 to-teal-600 flex items-center justify-center shadow-lg shadow-primary-500/25">
|
<div className="w-11 h-11 rounded-2xl bg-gradient-to-br from-primary-500 to-teal-600 flex items-center justify-center shadow-lg shadow-primary-500/25">
|
||||||
@@ -371,7 +371,7 @@ export default function Stats() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>}
|
||||||
|
|
||||||
<main className="relative max-w-lg mx-auto px-5 py-6 space-y-8">
|
<main className="relative max-w-lg mx-auto px-5 py-6 space-y-8">
|
||||||
|
|
||||||
@@ -689,7 +689,7 @@ export default function Stats() {
|
|||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<Navigation />
|
{!embedded && <Navigation />}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ function formatDueDate(dateStr) {
|
|||||||
return format(date, 'd MMM', { locale: ru })
|
return format(date, 'd MMM', { locale: ru })
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Tasks() {
|
export default function Tasks({ embedded = false }) {
|
||||||
const [showCreate, setShowCreate] = useState(false)
|
const [showCreate, setShowCreate] = useState(false)
|
||||||
const [editingTask, setEditingTask] = useState(null)
|
const [editingTask, setEditingTask] = useState(null)
|
||||||
const [filter, setFilter] = useState('active')
|
const [filter, setFilter] = useState('active')
|
||||||
@@ -68,8 +68,8 @@ export default function Tasks() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-surface-50 dark:bg-gray-950 gradient-mesh pb-24 transition-colors duration-300">
|
<div className={embedded ? "" : "min-h-screen bg-surface-50 dark:bg-gray-950 gradient-mesh pb-24 transition-colors duration-300"}>
|
||||||
<header className="bg-white/70 dark:bg-gray-900/70 backdrop-blur-xl border-b border-gray-100/50 dark:border-gray-800 sticky top-0 z-10">
|
{!embedded && <header className="bg-white/70 dark:bg-gray-900/70 backdrop-blur-xl border-b border-gray-100/50 dark:border-gray-800 sticky top-0 z-10">
|
||||||
<div className="max-w-lg mx-auto px-4 py-4">
|
<div className="max-w-lg mx-auto px-4 py-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-xl font-display font-bold text-gray-900 dark:text-white">Задачи</h1>
|
<h1 className="text-xl font-display font-bold text-gray-900 dark:text-white">Задачи</h1>
|
||||||
@@ -99,7 +99,7 @@ export default function Tasks() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>}
|
||||||
|
|
||||||
<main className="max-w-lg mx-auto px-4 py-6">
|
<main className="max-w-lg mx-auto px-4 py-6">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
@@ -145,7 +145,7 @@ export default function Tasks() {
|
|||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<Navigation />
|
{!embedded && <Navigation />}
|
||||||
<CreateTaskModal open={showCreate} onClose={() => setShowCreate(false)} />
|
<CreateTaskModal open={showCreate} onClose={() => setShowCreate(false)} />
|
||||||
<EditTaskModal open={!!editingTask} onClose={() => setEditingTask(null)} task={editingTask} />
|
<EditTaskModal open={!!editingTask} onClose={() => setEditingTask(null)} task={editingTask} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
52
src/pages/Tracker.jsx
Normal file
52
src/pages/Tracker.jsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { useState, lazy, Suspense } from "react"
|
||||||
|
import Navigation from "../components/Navigation"
|
||||||
|
|
||||||
|
// Import pages as components (they render their own content but we strip their Navigation)
|
||||||
|
import HabitsContent from "./Habits"
|
||||||
|
import TasksContent from "./Tasks"
|
||||||
|
import StatsContent from "./Stats"
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ key: "habits", label: "Привычки", icon: "🎯" },
|
||||||
|
{ key: "tasks", label: "Задачи", icon: "✅" },
|
||||||
|
{ key: "stats", label: "Статистика", icon: "📊" },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function Tracker() {
|
||||||
|
const [activeTab, setActiveTab] = useState("habits")
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-surface-50 dark:bg-gray-950 gradient-mesh pb-24">
|
||||||
|
<header className="bg-white/70 dark:bg-gray-900/70 backdrop-blur-xl border-b border-gray-100/50 dark:border-gray-800 sticky top-0 z-10">
|
||||||
|
<div className="max-w-lg mx-auto px-4 py-4">
|
||||||
|
<h1 className="text-xl font-display font-bold text-gray-900 dark:text-white">
|
||||||
|
📊 Трекер
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div className="max-w-lg mx-auto px-4 pb-3 flex gap-2">
|
||||||
|
{tabs.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.key}
|
||||||
|
onClick={() => setActiveTab(t.key)}
|
||||||
|
className={`flex-1 py-2 rounded-xl text-sm font-semibold transition ${
|
||||||
|
activeTab === t.key
|
||||||
|
? "bg-primary-500 text-white"
|
||||||
|
: "bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t.icon} {t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{activeTab === "habits" && <HabitsContent embedded />}
|
||||||
|
{activeTab === "tasks" && <TasksContent embedded />}
|
||||||
|
{activeTab === "stats" && <StatsContent embedded />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Navigation />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user