Add language switching and enhance task/financial management features

Implement i18next for language switching in the header, enhance task and financial schemas, and add BudgetPlanner.tsx.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: ff0be73b-afdd-4747-978b-bb8301fb0a82
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/9777c70b-fc38-4831-8d6b-78dfffe041b0/012e00c6-f380-4e55-bcb9-10f9adbfbb7b.jpg
This commit is contained in:
ghaddaditw
2025-06-08 06:43:12 +00:00
parent 5cae99424f
commit ff549aed8b
3 changed files with 598 additions and 3 deletions
@@ -0,0 +1,472 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Progress } from "@/components/ui/progress";
import { Badge } from "@/components/ui/badge";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useToast } from "@/hooks/use-toast";
import { apiRequest } from "@/lib/queryClient";
import { Plus, TrendingUp, TrendingDown, AlertTriangle, Edit, Trash2 } from "lucide-react";
const budgetSchema = z.object({
name: z.string().min(1, "Budget name is required"),
amount: z.number().min(0.01, "Amount must be greater than 0"),
period: z.enum(["weekly", "monthly", "yearly"]),
categoryId: z.number().optional(),
alertThreshold: z.number().min(1).max(100).default(80),
isActive: z.boolean().default(true),
startDate: z.string(),
endDate: z.string().optional(),
});
type BudgetFormData = z.infer<typeof budgetSchema>;
interface Budget {
id: number;
name: string;
amount: number;
period: string;
categoryId?: number;
category?: { name: string; color: string };
alertThreshold: number;
isActive: boolean;
startDate: string;
endDate?: string;
used: number;
remaining: number;
percentage: number;
createdAt: string;
}
export function BudgetPlanner() {
const { t } = useTranslation();
const { toast } = useToast();
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [editingBudget, setEditingBudget] = useState<Budget | null>(null);
const form = useForm<BudgetFormData>({
resolver: zodResolver(budgetSchema),
defaultValues: {
name: "",
amount: 0,
period: "monthly",
alertThreshold: 80,
isActive: true,
startDate: new Date().toISOString().split('T')[0],
},
});
const { data: budgets = [], isLoading } = useQuery({
queryKey: ["/api/finances/budgets"],
});
const { data: categories = [] } = useQuery({
queryKey: ["/api/finances/categories"],
});
const createBudgetMutation = useMutation({
mutationFn: (data: BudgetFormData) => apiRequest("/api/finances/budgets", {
method: "POST",
body: JSON.stringify(data),
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/finances/budgets"] });
setOpen(false);
form.reset();
toast({
title: t("success.created"),
description: t("finances.budgetCreated"),
});
},
});
const updateBudgetMutation = useMutation({
mutationFn: ({ id, ...data }: BudgetFormData & { id: number }) =>
apiRequest(`/api/finances/budgets/${id}`, {
method: "PATCH",
body: JSON.stringify(data),
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/finances/budgets"] });
setEditingBudget(null);
form.reset();
toast({
title: t("success.updated"),
description: t("finances.budgetUpdated"),
});
},
});
const deleteBudgetMutation = useMutation({
mutationFn: (id: number) => apiRequest(`/api/finances/budgets/${id}`, {
method: "DELETE",
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/finances/budgets"] });
toast({
title: t("success.deleted"),
description: t("finances.budgetDeleted"),
});
},
});
const handleSubmit = (data: BudgetFormData) => {
if (editingBudget) {
updateBudgetMutation.mutate({ ...data, id: editingBudget.id });
} else {
createBudgetMutation.mutate(data);
}
};
const handleEdit = (budget: Budget) => {
setEditingBudget(budget);
form.reset({
name: budget.name,
amount: budget.amount,
period: budget.period as "weekly" | "monthly" | "yearly",
categoryId: budget.categoryId,
alertThreshold: budget.alertThreshold,
isActive: budget.isActive,
startDate: budget.startDate.split('T')[0],
endDate: budget.endDate?.split('T')[0],
});
setOpen(true);
};
const getBudgetStatus = (budget: Budget) => {
if (budget.percentage >= budget.alertThreshold) {
return { color: "destructive", icon: AlertTriangle };
} else if (budget.percentage >= 70) {
return { color: "yellow", icon: TrendingUp };
}
return { color: "green", icon: TrendingDown };
};
if (isLoading) {
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{[...Array(3)].map((_, i) => (
<Card key={i} className="animate-pulse">
<CardHeader className="space-y-2">
<div className="h-4 bg-muted rounded w-3/4"></div>
<div className="h-3 bg-muted rounded w-1/2"></div>
</CardHeader>
<CardContent>
<div className="space-y-3">
<div className="h-2 bg-muted rounded"></div>
<div className="flex justify-between">
<div className="h-3 bg-muted rounded w-1/4"></div>
<div className="h-3 bg-muted rounded w-1/4"></div>
</div>
</div>
</CardContent>
</Card>
))}
</div>
);
}
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h2 className="text-2xl font-bold">{t("finances.budgetPlanning")}</h2>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button onClick={() => { setEditingBudget(null); form.reset(); }}>
<Plus className="h-4 w-4 mr-2" />
{t("finances.createBudget")}
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>
{editingBudget ? t("finances.editBudget") : t("finances.createBudget")}
</DialogTitle>
<DialogDescription>
{t("finances.budgetDescription")}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t("finances.budgetName")}</FormLabel>
<FormControl>
<Input placeholder={t("finances.enterBudgetName")} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="amount"
render={({ field }) => (
<FormItem>
<FormLabel>{t("finances.amount")}</FormLabel>
<FormControl>
<Input
type="number"
step="0.01"
placeholder="0.00"
{...field}
onChange={(e) => field.onChange(parseFloat(e.target.value) || 0)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="period"
render={({ field }) => (
<FormItem>
<FormLabel>{t("finances.period")}</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t("finances.selectPeriod")} />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="weekly">{t("finances.weekly")}</SelectItem>
<SelectItem value="monthly">{t("finances.monthly")}</SelectItem>
<SelectItem value="yearly">{t("finances.yearly")}</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="categoryId"
render={({ field }) => (
<FormItem>
<FormLabel>{t("finances.category")} ({t("common.optional")})</FormLabel>
<Select onValueChange={(value) => field.onChange(parseInt(value))} value={field.value?.toString()}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t("finances.selectCategory")} />
</SelectTrigger>
</FormControl>
<SelectContent>
{categories.map((category: any) => (
<SelectItem key={category.id} value={category.id.toString()}>
<div className="flex items-center gap-2">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: category.color }}
/>
{category.name}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="alertThreshold"
render={({ field }) => (
<FormItem>
<FormLabel>{t("finances.alertThreshold")} (%)</FormLabel>
<FormControl>
<Input
type="number"
min="1"
max="100"
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value) || 80)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="startDate"
render={({ field }) => (
<FormItem>
<FormLabel>{t("finances.startDate")}</FormLabel>
<FormControl>
<Input type="date" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="endDate"
render={({ field }) => (
<FormItem>
<FormLabel>{t("finances.endDate")} ({t("common.optional")})</FormLabel>
<FormControl>
<Input type="date" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="isActive"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">{t("finances.activeBudget")}</FormLabel>
<div className="text-sm text-muted-foreground">
{t("finances.activeBudgetDescription")}
</div>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<div className="flex justify-end space-x-2">
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
{t("common.cancel")}
</Button>
<Button type="submit" disabled={createBudgetMutation.isPending || updateBudgetMutation.isPending}>
{editingBudget ? t("common.update") : t("common.create")}
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{budgets.map((budget: Budget) => {
const status = getBudgetStatus(budget);
const StatusIcon = status.icon;
return (
<Card key={budget.id} className="relative">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-lg">{budget.name}</CardTitle>
<div className="flex items-center space-x-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleEdit(budget)}
className="h-8 w-8 p-0"
>
<Edit className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => deleteBudgetMutation.mutate(budget.id)}
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
<CardDescription>
{budget.category?.name && (
<Badge variant="secondary" className="mr-2">
<div
className="w-2 h-2 rounded-full mr-1"
style={{ backgroundColor: budget.category.color }}
/>
{budget.category.name}
</Badge>
)}
{t(`finances.${budget.period}`)}
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">${budget.used.toFixed(2)} / ${budget.amount.toFixed(2)}</span>
<div className="flex items-center space-x-1">
<StatusIcon className={`h-4 w-4 ${status.color === 'destructive' ? 'text-destructive' : status.color === 'yellow' ? 'text-yellow-500' : 'text-green-500'}`} />
<span className="text-sm font-medium">{budget.percentage.toFixed(0)}%</span>
</div>
</div>
<Progress value={budget.percentage} className="h-2" />
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-muted-foreground">{t("finances.budgetRemaining")}</p>
<p className="font-medium">${budget.remaining.toFixed(2)}</p>
</div>
<div>
<p className="text-muted-foreground">{t("finances.alertThreshold")}</p>
<p className="font-medium">{budget.alertThreshold}%</p>
</div>
</div>
{!budget.isActive && (
<Badge variant="outline" className="w-full justify-center">
{t("finances.inactive")}
</Badge>
)}
</div>
</CardContent>
</Card>
);
})}
</div>
{budgets.length === 0 && (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<TrendingUp className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2">{t("finances.noBudgets")}</h3>
<p className="text-muted-foreground text-center mb-4">
{t("finances.noBudgetsDescription")}
</p>
<Button onClick={() => setOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
{t("finances.createFirstBudget")}
</Button>
</CardContent>
</Card>
)}
</div>
);
}
+6
View File
@@ -12,6 +12,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
import {
Moon,
Sun,
@@ -21,10 +22,12 @@ import {
LogOut,
Activity
} from "lucide-react";
import { useTranslation } from "react-i18next";
export default function Header() {
const { user, logout } = useAuth();
const { theme, toggleTheme } = useTheme();
const { t } = useTranslation();
const [, setLocation] = useLocation();
const handleLogout = async () => {
@@ -76,6 +79,9 @@ export default function Header() {
<span className="text-xs text-muted-foreground hidden sm:inline">Voice Ready</span>
</div>
{/* Language Switcher */}
<LanguageSwitcher />
{/* Theme Toggle */}
<Button
variant="ghost"
+120 -3
View File
@@ -60,7 +60,7 @@ export const sessions = pgTable("sessions", {
createdAt: timestamp("created_at").defaultNow(),
});
// Tasks table
// Enhanced Tasks table
export const tasks = pgTable("tasks", {
id: serial("id").primaryKey(),
userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
@@ -72,22 +72,139 @@ export const tasks = pgTable("tasks", {
completedAt: timestamp("completed_at"),
createdViaVoice: boolean("created_via_voice").default(false),
voiceTranscription: text("voice_transcription"),
// Enhanced fields
categoryId: integer("category_id").references(() => taskCategories.id),
projectId: integer("project_id").references(() => projects.id),
parentTaskId: integer("parent_task_id").references(() => tasks.id),
assigneeId: integer("assignee_id").references(() => users.id),
tags: text("tags").array(),
estimatedTime: integer("estimated_time"), // in minutes
actualTime: integer("actual_time"), // in minutes
attachments: text("attachments").array(),
isTemplate: boolean("is_template").default(false),
templateId: integer("template_id").references(() => tasks.id),
createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at").defaultNow(),
});
// Financial records table
// Task Categories
export const taskCategories = pgTable("task_categories", {
id: serial("id").primaryKey(),
userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
name: text("name").notNull(),
color: varchar("color", { length: 7 }), // hex color
description: text("description"),
createdAt: timestamp("created_at").defaultNow(),
});
// Projects
export const projects = pgTable("projects", {
id: serial("id").primaryKey(),
userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
name: text("name").notNull(),
description: text("description"),
status: varchar("status", { length: 50 }).notNull().default("active"),
color: varchar("color", { length: 7 }),
deadline: timestamp("deadline"),
createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at").defaultNow(),
});
// Time Tracking
export const timeTracking = pgTable("time_tracking", {
id: serial("id").primaryKey(),
taskId: integer("task_id").notNull().references(() => tasks.id, { onDelete: "cascade" }),
userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
startTime: timestamp("start_time").notNull(),
endTime: timestamp("end_time"),
duration: integer("duration"), // in minutes
description: text("description"),
createdAt: timestamp("created_at").defaultNow(),
});
// Enhanced Financial records table
export const financialRecords = pgTable("financial_records", {
id: serial("id").primaryKey(),
userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
type: varchar("type", { length: 10 }).notNull(), // income, expense
amount: numeric("amount", { precision: 12, scale: 2 }).notNull(),
category: text("category").notNull(),
categoryId: integer("category_id").references(() => financialCategories.id),
description: text("description"),
date: timestamp("date").notNull(),
createdViaVoice: boolean("created_via_voice").default(false),
voiceTranscription: text("voice_transcription"),
metadata: json("metadata"), // For additional data like receipt info
// Enhanced fields
tags: text("tags").array(),
recurringId: integer("recurring_id").references(() => recurringTransactions.id),
budgetId: integer("budget_id").references(() => budgets.id),
receipt: text("receipt"), // file path or URL
notes: text("notes"),
createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at").defaultNow(),
});
// Financial Categories
export const financialCategories = pgTable("financial_categories", {
id: serial("id").primaryKey(),
userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
name: text("name").notNull(),
type: varchar("type", { length: 10 }).notNull(), // income, expense
color: varchar("color", { length: 7 }),
icon: varchar("icon", { length: 50 }),
description: text("description"),
isDefault: boolean("is_default").default(false),
createdAt: timestamp("created_at").defaultNow(),
});
// Budgets
export const budgets = pgTable("budgets", {
id: serial("id").primaryKey(),
userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
name: text("name").notNull(),
categoryId: integer("category_id").references(() => financialCategories.id),
amount: numeric("amount", { precision: 12, scale: 2 }).notNull(),
period: varchar("period", { length: 20 }).notNull(), // monthly, yearly, weekly
startDate: timestamp("start_date").notNull(),
endDate: timestamp("end_date"),
alertThreshold: integer("alert_threshold").default(80), // percentage
isActive: boolean("is_active").default(true),
createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at").defaultNow(),
});
// Recurring Transactions
export const recurringTransactions = pgTable("recurring_transactions", {
id: serial("id").primaryKey(),
userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
name: text("name").notNull(),
type: varchar("type", { length: 10 }).notNull(), // income, expense
amount: numeric("amount", { precision: 12, scale: 2 }).notNull(),
categoryId: integer("category_id").references(() => financialCategories.id),
frequency: varchar("frequency", { length: 20 }).notNull(), // daily, weekly, monthly, yearly
startDate: timestamp("start_date").notNull(),
endDate: timestamp("end_date"),
nextDue: timestamp("next_due").notNull(),
isActive: boolean("is_active").default(true),
description: text("description"),
tags: text("tags").array(),
createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at").defaultNow(),
});
// Bill Reminders
export const billReminders = pgTable("bill_reminders", {
id: serial("id").primaryKey(),
userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
name: text("name").notNull(),
amount: numeric("amount", { precision: 12, scale: 2 }),
dueDate: timestamp("due_date").notNull(),
frequency: varchar("frequency", { length: 20 }), // one-time, monthly, yearly
categoryId: integer("category_id").references(() => financialCategories.id),
reminderDays: integer("reminder_days").array(), // days before due date to remind
isPaid: boolean("is_paid").default(false),
paidDate: timestamp("paid_date"),
notes: text("notes"),
createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at").defaultNow(),
});