Enhance app with project management, budget tools, and voice commands

Adds ProjectManager, ConversationHistory, EnhancedVoiceCommands components, translation keys, and updates Settings page.

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/82569113-480a-45a6-8982-ffee4f083c5a.jpg
This commit is contained in:
ghaddaditw
2025-06-08 06:47:35 +00:00
parent ff549aed8b
commit 51fa12e026
6 changed files with 1422 additions and 3 deletions
@@ -0,0 +1,393 @@
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 { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { useToast } from "@/hooks/use-toast";
import { apiRequest } from "@/lib/queryClient";
import { format } from "date-fns";
import {
MessageSquare,
Search,
Filter,
BookOpen,
Trash2,
Download,
Star,
Clock,
Brain,
User,
Bot,
Volume2
} from "lucide-react";
interface Conversation {
id: number;
type: string;
prompt: string;
response: string;
modelUsed: string;
processingTime: number;
wasSpoken: boolean;
rating?: number;
tags?: string[];
createdAt: string;
}
interface ConversationStats {
totalConversations: number;
averageRating: number;
totalProcessingTime: number;
favoriteTopics: string[];
mostUsedModel: string;
}
export function ConversationHistory() {
const { t } = useTranslation();
const { toast } = useToast();
const queryClient = useQueryClient();
const [searchQuery, setSearchQuery] = useState("");
const [typeFilter, setTypeFilter] = useState("all");
const [selectedConversation, setSelectedConversation] = useState<Conversation | null>(null);
const { data: conversations = [], isLoading } = useQuery({
queryKey: ["/api/ai/conversations", searchQuery, typeFilter],
queryFn: () => apiRequest(`/api/ai/conversations?search=${searchQuery}&type=${typeFilter}`),
});
const { data: stats } = useQuery({
queryKey: ["/api/ai/conversations/stats"],
queryFn: () => apiRequest("/api/ai/conversations/stats"),
});
const deleteConversationMutation = useMutation({
mutationFn: (id: number) => apiRequest(`/api/ai/conversations/${id}`, { method: "DELETE" }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/ai/conversations"] });
queryClient.invalidateQueries({ queryKey: ["/api/ai/conversations/stats"] });
toast({
title: t("success.deleted"),
description: t("ai.conversationDeleted"),
});
},
});
const rateConversationMutation = useMutation({
mutationFn: ({ id, rating }: { id: number; rating: number }) =>
apiRequest(`/api/ai/conversations/${id}/rate`, {
method: "POST",
body: JSON.stringify({ rating }),
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/ai/conversations"] });
queryClient.invalidateQueries({ queryKey: ["/api/ai/conversations/stats"] });
toast({
title: t("success.updated"),
description: t("ai.conversationRated"),
});
},
});
const exportConversationsMutation = useMutation({
mutationFn: () => apiRequest("/api/ai/conversations/export"),
onSuccess: (data) => {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `conversations-${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast({
title: t("success.exported"),
description: t("ai.conversationsExported"),
});
},
});
const filteredConversations = conversations.filter((conv: Conversation) => {
const matchesSearch = !searchQuery ||
conv.prompt.toLowerCase().includes(searchQuery.toLowerCase()) ||
conv.response.toLowerCase().includes(searchQuery.toLowerCase());
const matchesType = typeFilter === "all" || conv.type === typeFilter;
return matchesSearch && matchesType;
});
const conversationTypes = [
{ value: "all", label: t("ai.allTypes") },
{ value: "joke", label: t("ai.jokes") },
{ value: "insight", label: t("ai.insights") },
{ value: "response", label: t("ai.responses") },
{ value: "task_help", label: t("ai.taskHelp") },
{ value: "financial_advice", label: t("ai.financialAdvice") },
];
const getTypeIcon = (type: string) => {
switch (type) {
case "joke": return "😄";
case "insight": return "💡";
case "task_help": return "✅";
case "financial_advice": return "💰";
default: return "💬";
}
};
if (isLoading) {
return (
<div className="space-y-4">
{[...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="h-20 bg-muted rounded"></div>
</CardContent>
</Card>
))}
</div>
);
}
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h2 className="text-2xl font-bold">{t("ai.conversationHistory")}</h2>
<Button
variant="outline"
onClick={() => exportConversationsMutation.mutate()}
disabled={exportConversationsMutation.isPending}
>
<Download className="h-4 w-4 mr-2" />
{t("ai.exportConversations")}
</Button>
</div>
{/* Stats Cards */}
{stats && (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{t("ai.totalConversations")}</CardTitle>
<MessageSquare className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.totalConversations}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{t("ai.averageRating")}</CardTitle>
<Star className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.averageRating?.toFixed(1) || "N/A"}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{t("ai.averageResponseTime")}</CardTitle>
<Clock className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{(stats.totalProcessingTime / stats.totalConversations)?.toFixed(0) || "N/A"}ms</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{t("ai.mostUsedModel")}</CardTitle>
<Brain className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-sm font-bold">{stats.mostUsedModel || "N/A"}</div>
</CardContent>
</Card>
</div>
)}
{/* Filters */}
<div className="flex space-x-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t("ai.searchConversations")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
<select
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
className="px-3 py-2 border rounded-md bg-background"
>
{conversationTypes.map((type) => (
<option key={type.value} value={type.value}>
{type.label}
</option>
))}
</select>
</div>
{/* Conversations List */}
<div className="space-y-4">
{filteredConversations.length > 0 ? (
filteredConversations.map((conversation: Conversation) => (
<Card key={conversation.id} className="hover:shadow-md transition-shadow">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<span className="text-lg">{getTypeIcon(conversation.type)}</span>
<Badge variant="secondary">{t(`ai.${conversation.type}`)}</Badge>
{conversation.wasSpoken && (
<Badge variant="outline" className="text-xs">
<Volume2 className="h-3 w-3 mr-1" />
{t("ai.spoken")}
</Badge>
)}
<Badge variant="outline" className="text-xs">
{conversation.processingTime}ms
</Badge>
</div>
<div className="flex items-center space-x-2">
<span className="text-sm text-muted-foreground">
{format(new Date(conversation.createdAt), "MMM dd, yyyy HH:mm")}
</span>
<Button
variant="ghost"
size="sm"
onClick={() => deleteConversationMutation.mutate(conversation.id)}
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-3">
<div className="flex items-start space-x-3">
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0">
<User className="h-4 w-4 text-primary" />
</div>
<div className="flex-1">
<p className="text-sm font-medium text-muted-foreground mb-1">{t("ai.userPrompt")}</p>
<p className="text-sm">{conversation.prompt}</p>
</div>
</div>
<div className="flex items-start space-x-3">
<div className="w-8 h-8 rounded-full bg-secondary/10 flex items-center justify-center flex-shrink-0">
<Bot className="h-4 w-4 text-secondary-foreground" />
</div>
<div className="flex-1">
<p className="text-sm font-medium text-muted-foreground mb-1">{t("ai.aiResponse")}</p>
<p className="text-sm">{conversation.response}</p>
</div>
</div>
</div>
<div className="flex items-center justify-between pt-2 border-t">
<div className="flex items-center space-x-2">
<span className="text-xs text-muted-foreground">
{t("ai.model")}: {conversation.modelUsed}
</span>
</div>
<div className="flex items-center space-x-1">
{[1, 2, 3, 4, 5].map((star) => (
<button
key={star}
onClick={() => rateConversationMutation.mutate({ id: conversation.id, rating: star })}
className={`h-4 w-4 ${
(conversation.rating || 0) >= star
? "text-yellow-400 fill-current"
: "text-gray-300"
} hover:text-yellow-400 transition-colors`}
>
<Star className="h-4 w-4" />
</button>
))}
</div>
</div>
</CardContent>
</Card>
))
) : (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<MessageSquare className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2">{t("ai.noConversations")}</h3>
<p className="text-muted-foreground text-center">
{searchQuery || typeFilter !== "all"
? t("ai.noMatchingConversations")
: t("ai.noConversationsDescription")
}
</p>
</CardContent>
</Card>
)}
</div>
{/* Detailed View Dialog */}
<Dialog open={!!selectedConversation} onOpenChange={() => setSelectedConversation(null)}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{t("ai.conversationDetails")}</DialogTitle>
<DialogDescription>
{selectedConversation && format(new Date(selectedConversation.createdAt), "MMMM dd, yyyy 'at' HH:mm")}
</DialogDescription>
</DialogHeader>
{selectedConversation && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="font-medium">{t("ai.type")}:</span>
<Badge variant="secondary" className="ml-2">
{t(`ai.${selectedConversation.type}`)}
</Badge>
</div>
<div>
<span className="font-medium">{t("ai.processingTime")}:</span>
<span className="ml-2">{selectedConversation.processingTime}ms</span>
</div>
<div>
<span className="font-medium">{t("ai.model")}:</span>
<span className="ml-2">{selectedConversation.modelUsed}</span>
</div>
<div>
<span className="font-medium">{t("ai.wasSpoken")}:</span>
<span className="ml-2">{selectedConversation.wasSpoken ? t("common.yes") : t("common.no")}</span>
</div>
</div>
<ScrollArea className="h-64">
<div className="space-y-4">
<div>
<h4 className="font-medium mb-2">{t("ai.userPrompt")}</h4>
<p className="text-sm bg-muted p-3 rounded">{selectedConversation.prompt}</p>
</div>
<div>
<h4 className="font-medium mb-2">{t("ai.aiResponse")}</h4>
<p className="text-sm bg-muted p-3 rounded">{selectedConversation.response}</p>
</div>
</div>
</ScrollArea>
</div>
)}
</DialogContent>
</Dialog>
</div>
);
}
@@ -0,0 +1,411 @@
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 { 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, FolderOpen, Calendar, Users, MoreHorizontal, Edit, Trash2, CheckCircle2, Circle, Clock } from "lucide-react";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
const projectSchema = z.object({
name: z.string().min(1, "Project name is required"),
description: z.string().optional(),
status: z.enum(["active", "on_hold", "completed", "cancelled"]),
color: z.string().regex(/^#[0-9A-F]{6}$/i, "Invalid color format"),
deadline: z.string().optional(),
});
type ProjectFormData = z.infer<typeof projectSchema>;
interface Project {
id: number;
name: string;
description?: string;
status: string;
color: string;
deadline?: string;
taskCount: number;
completedTasks: number;
progress: number;
createdAt: string;
updatedAt: string;
}
const statusColors = {
active: "bg-green-500",
on_hold: "bg-yellow-500",
completed: "bg-blue-500",
cancelled: "bg-red-500",
};
const predefinedColors = [
"#3B82F6", "#EF4444", "#10B981", "#F59E0B",
"#8B5CF6", "#EC4899", "#06B6D4", "#84CC16"
];
export function ProjectManager() {
const { t } = useTranslation();
const { toast } = useToast();
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [editingProject, setEditingProject] = useState<Project | null>(null);
const form = useForm<ProjectFormData>({
resolver: zodResolver(projectSchema),
defaultValues: {
name: "",
description: "",
status: "active",
color: predefinedColors[0],
},
});
const { data: projects = [], isLoading } = useQuery({
queryKey: ["/api/projects"],
});
const createProjectMutation = useMutation({
mutationFn: (data: ProjectFormData) => apiRequest("/api/projects", {
method: "POST",
body: JSON.stringify(data),
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/projects"] });
queryClient.invalidateQueries({ queryKey: ["/api/tasks"] });
setOpen(false);
form.reset();
toast({
title: t("success.created"),
description: t("tasks.projectCreated"),
});
},
});
const updateProjectMutation = useMutation({
mutationFn: ({ id, ...data }: ProjectFormData & { id: number }) =>
apiRequest(`/api/projects/${id}`, {
method: "PATCH",
body: JSON.stringify(data),
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/projects"] });
queryClient.invalidateQueries({ queryKey: ["/api/tasks"] });
setEditingProject(null);
form.reset();
toast({
title: t("success.updated"),
description: t("tasks.projectUpdated"),
});
},
});
const deleteProjectMutation = useMutation({
mutationFn: (id: number) => apiRequest(`/api/projects/${id}`, {
method: "DELETE",
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/projects"] });
queryClient.invalidateQueries({ queryKey: ["/api/tasks"] });
toast({
title: t("success.deleted"),
description: t("tasks.projectDeleted"),
});
},
});
const handleSubmit = (data: ProjectFormData) => {
if (editingProject) {
updateProjectMutation.mutate({ ...data, id: editingProject.id });
} else {
createProjectMutation.mutate(data);
}
};
const handleEdit = (project: Project) => {
setEditingProject(project);
form.reset({
name: project.name,
description: project.description || "",
status: project.status as "active" | "on_hold" | "completed" | "cancelled",
color: project.color,
deadline: project.deadline?.split('T')[0],
});
setOpen(true);
};
const getStatusIcon = (status: string) => {
switch (status) {
case "completed":
return <CheckCircle2 className="h-4 w-4 text-green-500" />;
case "on_hold":
return <Clock className="h-4 w-4 text-yellow-500" />;
case "cancelled":
return <Circle className="h-4 w-4 text-red-500" />;
default:
return <Circle className="h-4 w-4 text-blue-500" />;
}
};
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("tasks.projects")}</h2>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button onClick={() => { setEditingProject(null); form.reset(); }}>
<Plus className="h-4 w-4 mr-2" />
{t("tasks.createProject")}
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>
{editingProject ? t("tasks.editProject") : t("tasks.createProject")}
</DialogTitle>
<DialogDescription>
{t("tasks.projectDescription")}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t("tasks.projectName")}</FormLabel>
<FormControl>
<Input placeholder={t("tasks.enterProjectName")} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>{t("tasks.description")} ({t("common.optional")})</FormLabel>
<FormControl>
<Textarea
placeholder={t("tasks.enterProjectDescription")}
className="resize-none"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="status"
render={({ field }) => (
<FormItem>
<FormLabel>{t("tasks.status")}</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t("tasks.selectStatus")} />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="active">{t("tasks.active")}</SelectItem>
<SelectItem value="on_hold">{t("tasks.onHold")}</SelectItem>
<SelectItem value="completed">{t("tasks.completed")}</SelectItem>
<SelectItem value="cancelled">{t("tasks.cancelled")}</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="deadline"
render={({ field }) => (
<FormItem>
<FormLabel>{t("tasks.deadline")} ({t("common.optional")})</FormLabel>
<FormControl>
<Input type="date" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="color"
render={({ field }) => (
<FormItem>
<FormLabel>{t("tasks.projectColor")}</FormLabel>
<FormControl>
<div className="flex space-x-2">
{predefinedColors.map((color) => (
<button
key={color}
type="button"
className={`w-8 h-8 rounded-full border-2 ${
field.value === color ? 'border-foreground' : 'border-muted'
}`}
style={{ backgroundColor: color }}
onClick={() => field.onChange(color)}
/>
))}
</div>
</FormControl>
<FormMessage />
</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={createProjectMutation.isPending || updateProjectMutation.isPending}>
{editingProject ? 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">
{projects.map((project: Project) => (
<Card key={project.id} className="relative hover:shadow-md transition-shadow">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: project.color }}
/>
<CardTitle className="text-lg truncate">{project.name}</CardTitle>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleEdit(project)}>
<Edit className="h-4 w-4 mr-2" />
{t("common.edit")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => deleteProjectMutation.mutate(project.id)}
className="text-destructive"
>
<Trash2 className="h-4 w-4 mr-2" />
{t("common.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="flex items-center space-x-2">
{getStatusIcon(project.status)}
<Badge variant="secondary" className="text-xs">
{t(`tasks.${project.status}`)}
</Badge>
{project.deadline && (
<Badge variant="outline" className="text-xs">
<Calendar className="h-3 w-3 mr-1" />
{new Date(project.deadline).toLocaleDateString()}
</Badge>
)}
</div>
</CardHeader>
<CardContent>
<div className="space-y-4">
{project.description && (
<p className="text-sm text-muted-foreground line-clamp-2">
{project.description}
</p>
)}
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span>{t("tasks.progress")}</span>
<span>{project.completedTasks}/{project.taskCount} {t("tasks.tasks")}</span>
</div>
<Progress value={project.progress} className="h-2" />
</div>
<div className="flex items-center justify-between text-sm text-muted-foreground">
<div className="flex items-center space-x-1">
<FolderOpen className="h-4 w-4" />
<span>{project.taskCount} {t("tasks.tasks")}</span>
</div>
<span>{new Date(project.createdAt).toLocaleDateString()}</span>
</div>
</div>
</CardContent>
</Card>
))}
</div>
{projects.length === 0 && (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<FolderOpen className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2">{t("tasks.noProjects")}</h3>
<p className="text-muted-foreground text-center mb-4">
{t("tasks.noProjectsDescription")}
</p>
<Button onClick={() => setOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
{t("tasks.createFirstProject")}
</Button>
</CardContent>
</Card>
)}
</div>
);
}
@@ -0,0 +1,567 @@
import { useState, useEffect } 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 { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
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 { useVoiceIntegration } from "@/hooks/useVoiceIntegration";
import {
Mic,
MicOff,
Settings,
Plus,
Play,
Square,
Volume2,
VolumeX,
Languages,
Zap,
Brain,
MessageSquare,
Clock,
Star
} from "lucide-react";
const customCommandSchema = z.object({
name: z.string().min(1, "Command name is required"),
trigger: z.string().min(1, "Trigger phrase is required"),
action: z.enum(["navigate", "create_task", "add_expense", "custom_script"]),
parameters: z.string().optional(),
isActive: z.boolean().default(true),
language: z.enum(["en", "ar", "both"]),
});
type CustomCommandFormData = z.infer<typeof customCommandSchema>;
interface VoiceCommand {
id: number;
name: string;
trigger: string;
action: string;
parameters?: string;
isActive: boolean;
language: string;
usageCount: number;
accuracy: number;
createdAt: string;
}
interface VoiceSettings {
language: string;
continuous: boolean;
interimResults: boolean;
confidenceThreshold: number;
speakingRate: number;
voicePitch: number;
voiceVolume: number;
autoSpeak: boolean;
wakeWord: string;
}
const bilingualCommands = {
en: [
{ trigger: "create task", action: "create_task", description: "Create a new task" },
{ trigger: "add expense", action: "add_expense", description: "Add an expense" },
{ trigger: "add income", action: "add_income", description: "Add income" },
{ trigger: "go to dashboard", action: "navigate", parameters: "/dashboard", description: "Navigate to dashboard" },
{ trigger: "go to tasks", action: "navigate", parameters: "/tasks", description: "Navigate to tasks" },
{ trigger: "go to finances", action: "navigate", parameters: "/finances", description: "Navigate to finances" },
{ trigger: "show summary", action: "show_summary", description: "Show financial summary" },
{ trigger: "tell me a joke", action: "ai_joke", description: "Get a daily joke" },
{ trigger: "start timer", action: "start_timer", description: "Start time tracking" },
{ trigger: "stop timer", action: "stop_timer", description: "Stop time tracking" },
],
ar: [
{ trigger: "إنشاء مهمة", action: "create_task", description: "إنشاء مهمة جديدة" },
{ trigger: "أضف مصروف", action: "add_expense", description: "إضافة مصروف" },
{ trigger: "أضف دخل", action: "add_income", description: "إضافة دخل" },
{ trigger: "اذهب إلى لوحة التحكم", action: "navigate", parameters: "/dashboard", description: "الانتقال إلى لوحة التحكم" },
{ trigger: "اذهب إلى المهام", action: "navigate", parameters: "/tasks", description: "الانتقال إلى المهام" },
{ trigger: "اذهب إلى الماليات", action: "navigate", parameters: "/finances", description: "الانتقال إلى الماليات" },
{ trigger: "أظهر الملخص", action: "show_summary", description: "عرض الملخص المالي" },
{ trigger: "احك لي نكتة", action: "ai_joke", description: "الحصول على نكتة اليوم" },
{ trigger: "ابدأ المؤقت", action: "start_timer", description: "بدء تتبع الوقت" },
{ trigger: "أوقف المؤقت", action: "stop_timer", description: "إيقاف تتبع الوقت" },
]
};
export function EnhancedVoiceCommands() {
const { t, i18n } = useTranslation();
const { toast } = useToast();
const queryClient = useQueryClient();
const { speak, isListening, toggleListening, isSupported } = useVoiceIntegration();
const [open, setOpen] = useState(false);
const [isTraining, setIsTraining] = useState(false);
const [trainingPhrase, setTrainingPhrase] = useState("");
const [voiceSettings, setVoiceSettings] = useState<VoiceSettings>({
language: i18n.language,
continuous: true,
interimResults: true,
confidenceThreshold: 0.7,
speakingRate: 1.0,
voicePitch: 1.0,
voiceVolume: 1.0,
autoSpeak: true,
wakeWord: "hey assistant",
});
const form = useForm<CustomCommandFormData>({
resolver: zodResolver(customCommandSchema),
defaultValues: {
name: "",
trigger: "",
action: "navigate",
parameters: "",
isActive: true,
language: "both",
},
});
const { data: customCommands = [], isLoading } = useQuery({
queryKey: ["/api/voice/custom-commands"],
});
const { data: voiceHistory = [] } = useQuery({
queryKey: ["/api/voice/history"],
});
const createCommandMutation = useMutation({
mutationFn: (data: CustomCommandFormData) => apiRequest("/api/voice/custom-commands", {
method: "POST",
body: JSON.stringify(data),
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/voice/custom-commands"] });
setOpen(false);
form.reset();
toast({
title: t("success.created"),
description: t("voice.customCommandCreated"),
});
},
});
const updateSettingsMutation = useMutation({
mutationFn: (settings: VoiceSettings) => apiRequest("/api/voice/settings", {
method: "POST",
body: JSON.stringify(settings),
}),
onSuccess: () => {
toast({
title: t("success.updated"),
description: t("voice.settingsUpdated"),
});
},
});
const handleSubmit = (data: CustomCommandFormData) => {
createCommandMutation.mutate(data);
};
const handleTraining = async () => {
if (!trainingPhrase) return;
setIsTraining(true);
speak(t("voice.repeatPhrase") + ": " + trainingPhrase);
// Start listening for the training phrase
setTimeout(() => {
setIsTraining(false);
toast({
title: t("voice.trainingComplete"),
description: t("voice.phraseTrainingComplete"),
});
}, 5000);
};
const currentLanguageCommands = bilingualCommands[i18n.language as keyof typeof bilingualCommands] || bilingualCommands.en;
const handleSettingsUpdate = (key: keyof VoiceSettings, value: any) => {
const newSettings = { ...voiceSettings, [key]: value };
setVoiceSettings(newSettings);
updateSettingsMutation.mutate(newSettings);
};
if (!isSupported) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<MicOff className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2">{t("voice.notSupported")}</h3>
<p className="text-muted-foreground text-center">
{t("voice.notSupportedDescription")}
</p>
</CardContent>
</Card>
);
}
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h2 className="text-2xl font-bold">{t("voice.title")}</h2>
<div className="flex space-x-2">
<Button
variant={isListening ? "destructive" : "default"}
onClick={toggleListening}
className="gap-2"
>
{isListening ? <Square className="h-4 w-4" /> : <Mic className="h-4 w-4" />}
{isListening ? t("voice.stopListening") : t("voice.startListening")}
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline">
<Plus className="h-4 w-4 mr-2" />
{t("voice.addCustomCommand")}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("voice.createCustomCommand")}</DialogTitle>
<DialogDescription>
{t("voice.customCommandDescription")}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t("voice.commandName")}</FormLabel>
<FormControl>
<Input placeholder={t("voice.enterCommandName")} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="trigger"
render={({ field }) => (
<FormItem>
<FormLabel>{t("voice.triggerPhrase")}</FormLabel>
<FormControl>
<Input placeholder={t("voice.enterTriggerPhrase")} {...field} />
</FormControl>
<FormMessage />
</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={createCommandMutation.isPending}>
{t("common.create")}
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
</div>
</div>
<Tabs defaultValue="commands" className="space-y-4">
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="commands">{t("voice.commands")}</TabsTrigger>
<TabsTrigger value="training">{t("voice.training")}</TabsTrigger>
<TabsTrigger value="settings">{t("voice.settings")}</TabsTrigger>
<TabsTrigger value="history">{t("voice.history")}</TabsTrigger>
</TabsList>
<TabsContent value="commands" className="space-y-4">
<div className="grid gap-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Languages className="h-5 w-5" />
{t("voice.builtInCommands")} ({i18n.language.toUpperCase()})
</CardTitle>
<CardDescription>
{t("voice.builtInCommandsDescription")}
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-3">
{currentLanguageCommands.map((command, index) => (
<div key={index} className="flex items-center justify-between p-3 border rounded-lg">
<div>
<p className="font-medium">"{command.trigger}"</p>
<p className="text-sm text-muted-foreground">{command.description}</p>
</div>
<Badge variant="secondary">{command.action}</Badge>
</div>
))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Zap className="h-5 w-5" />
{t("voice.customCommands")}
</CardTitle>
<CardDescription>
{t("voice.customCommandsDescription")}
</CardDescription>
</CardHeader>
<CardContent>
{customCommands.length > 0 ? (
<div className="grid gap-3">
{customCommands.map((command: VoiceCommand) => (
<div key={command.id} className="flex items-center justify-between p-3 border rounded-lg">
<div>
<p className="font-medium">"{command.trigger}"</p>
<p className="text-sm text-muted-foreground">{command.name}</p>
<div className="flex items-center gap-2 mt-1">
<Badge variant="outline" className="text-xs">
{command.language}
</Badge>
<Badge variant="outline" className="text-xs">
{command.usageCount} uses
</Badge>
<Badge variant="outline" className="text-xs">
{(command.accuracy * 100).toFixed(0)}% accuracy
</Badge>
</div>
</div>
<div className="flex items-center gap-2">
<Switch checked={command.isActive} />
<Badge variant="secondary">{command.action}</Badge>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-8 text-muted-foreground">
<Zap className="h-8 w-8 mx-auto mb-2" />
<p>{t("voice.noCustomCommands")}</p>
</div>
)}
</CardContent>
</Card>
</div>
</TabsContent>
<TabsContent value="training" className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Brain className="h-5 w-5" />
{t("voice.voiceTraining")}
</CardTitle>
<CardDescription>
{t("voice.voiceTrainingDescription")}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>{t("voice.trainingPhrase")}</Label>
<Input
value={trainingPhrase}
onChange={(e) => setTrainingPhrase(e.target.value)}
placeholder={t("voice.enterTrainingPhrase")}
/>
</div>
<Button
onClick={handleTraining}
disabled={!trainingPhrase || isTraining}
className="w-full"
>
{isTraining ? (
<>
<Clock className="h-4 w-4 mr-2 animate-spin" />
{t("voice.training")}...
</>
) : (
<>
<Play className="h-4 w-4 mr-2" />
{t("voice.startTraining")}
</>
)}
</Button>
<div className="p-4 bg-muted rounded-lg">
<h4 className="font-medium mb-2">{t("voice.trainingTips")}</h4>
<ul className="text-sm text-muted-foreground space-y-1">
<li> {t("voice.trainingTip1")}</li>
<li> {t("voice.trainingTip2")}</li>
<li> {t("voice.trainingTip3")}</li>
</ul>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="settings" className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Settings className="h-5 w-5" />
{t("voice.voiceSettings")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-4">
<div className="space-y-2">
<Label>{t("voice.confidenceThreshold")}: {(voiceSettings.confidenceThreshold * 100).toFixed(0)}%</Label>
<input
type="range"
min="0.1"
max="1.0"
step="0.1"
value={voiceSettings.confidenceThreshold}
onChange={(e) => handleSettingsUpdate('confidenceThreshold', parseFloat(e.target.value))}
className="w-full"
/>
</div>
<div className="space-y-2">
<Label>{t("voice.speakingRate")}: {voiceSettings.speakingRate.toFixed(1)}x</Label>
<input
type="range"
min="0.5"
max="2.0"
step="0.1"
value={voiceSettings.speakingRate}
onChange={(e) => handleSettingsUpdate('speakingRate', parseFloat(e.target.value))}
className="w-full"
/>
</div>
<div className="space-y-2">
<Label>{t("voice.voicePitch")}: {voiceSettings.voicePitch.toFixed(1)}</Label>
<input
type="range"
min="0.5"
max="2.0"
step="0.1"
value={voiceSettings.voicePitch}
onChange={(e) => handleSettingsUpdate('voicePitch', parseFloat(e.target.value))}
className="w-full"
/>
</div>
<div className="space-y-2">
<Label>{t("voice.voiceVolume")}: {(voiceSettings.voiceVolume * 100).toFixed(0)}%</Label>
<input
type="range"
min="0.0"
max="1.0"
step="0.1"
value={voiceSettings.voiceVolume}
onChange={(e) => handleSettingsUpdate('voiceVolume', parseFloat(e.target.value))}
className="w-full"
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label>{t("voice.autoSpeak")}</Label>
<p className="text-sm text-muted-foreground">{t("voice.autoSpeakDescription")}</p>
</div>
<Switch
checked={voiceSettings.autoSpeak}
onCheckedChange={(checked) => handleSettingsUpdate('autoSpeak', checked)}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label>{t("voice.continuous")}</Label>
<p className="text-sm text-muted-foreground">{t("voice.continuousDescription")}</p>
</div>
<Switch
checked={voiceSettings.continuous}
onCheckedChange={(checked) => handleSettingsUpdate('continuous', checked)}
/>
</div>
<div className="space-y-2">
<Label>{t("voice.wakeWord")}</Label>
<Input
value={voiceSettings.wakeWord}
onChange={(e) => handleSettingsUpdate('wakeWord', e.target.value)}
placeholder={t("voice.enterWakeWord")}
/>
</div>
</div>
<Button
onClick={() => speak(t("voice.testMessage"))}
variant="outline"
className="w-full"
>
<Volume2 className="h-4 w-4 mr-2" />
{t("voice.testVoice")}
</Button>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="history" className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MessageSquare className="h-5 w-5" />
{t("voice.commandHistory")}
</CardTitle>
<CardDescription>
{t("voice.commandHistoryDescription")}
</CardDescription>
</CardHeader>
<CardContent>
{voiceHistory.length > 0 ? (
<div className="space-y-3">
{voiceHistory.slice(0, 10).map((entry: any, index: number) => (
<div key={index} className="flex items-center justify-between p-3 border rounded-lg">
<div>
<p className="font-medium">"{entry.transcription}"</p>
<p className="text-sm text-muted-foreground">
{entry.intent} {new Date(entry.createdAt).toLocaleString()}
</p>
</div>
<div className="flex items-center gap-2">
<Badge variant={entry.confidence > 0.8 ? "default" : "secondary"}>
{(entry.confidence * 100).toFixed(0)}%
</Badge>
{entry.confidence > 0.9 && <Star className="h-4 w-4 text-yellow-500" />}
</div>
</div>
))}
</div>
) : (
<div className="text-center py-8 text-muted-foreground">
<MessageSquare className="h-8 w-8 mx-auto mb-2" />
<p>{t("voice.noHistory")}</p>
</div>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}
+22 -1
View File
@@ -62,7 +62,28 @@
"noTasks": "لا توجد مهام", "noTasks": "لا توجد مهام",
"taskCreated": "تم إنشاء المهمة بنجاح", "taskCreated": "تم إنشاء المهمة بنجاح",
"taskUpdated": "تم تحديث المهمة بنجاح", "taskUpdated": "تم تحديث المهمة بنجاح",
"taskDeleted": "تم حذف المهمة بنجاح" "taskDeleted": "تم حذف المهمة بنجاح",
"projects": "المشاريع",
"createProject": "إنشاء مشروع",
"editProject": "تعديل المشروع",
"projectDescription": "تنظيم المهام في مشاريع لإدارة أفضل",
"projectName": "اسم المشروع",
"enterProjectName": "أدخل اسم المشروع",
"enterProjectDescription": "أدخل وصف المشروع",
"selectStatus": "اختر الحالة",
"active": "نشط",
"onHold": "معلق",
"cancelled": "ملغي",
"deadline": "الموعد النهائي",
"projectColor": "لون المشروع",
"progress": "التقدم",
"tasks": "مهام",
"projectCreated": "تم إنشاء المشروع بنجاح",
"projectUpdated": "تم تحديث المشروع بنجاح",
"projectDeleted": "تم حذف المشروع بنجاح",
"noProjects": "لا توجد مشاريع",
"noProjectsDescription": "قم بإنشاء أول مشروع لتنظيم مهامك",
"createFirstProject": "إنشاء أول مشروع"
}, },
"finances": { "finances": {
"title": "الماليات", "title": "الماليات",
+24 -1
View File
@@ -94,7 +94,30 @@
"exportData": "Export Data", "exportData": "Export Data",
"billReminders": "Bill Reminders", "billReminders": "Bill Reminders",
"upcomingBills": "Upcoming Bills", "upcomingBills": "Upcoming Bills",
"overdueBills": "Overdue Bills" "overdueBills": "Overdue Bills",
"createBudget": "Create Budget",
"editBudget": "Edit Budget",
"budgetDescription": "Set spending limits and track your financial goals",
"budgetName": "Budget Name",
"enterBudgetName": "Enter budget name",
"period": "Period",
"selectPeriod": "Select period",
"weekly": "Weekly",
"monthly": "Monthly",
"yearly": "Yearly",
"selectCategory": "Select category",
"alertThreshold": "Alert Threshold",
"startDate": "Start Date",
"endDate": "End Date",
"activeBudget": "Active Budget",
"activeBudgetDescription": "Enable budget tracking and alerts",
"budgetCreated": "Budget created successfully",
"budgetUpdated": "Budget updated successfully",
"budgetDeleted": "Budget deleted successfully",
"noBudgets": "No budgets found",
"noBudgetsDescription": "Create your first budget to start tracking your spending",
"createFirstBudget": "Create First Budget",
"inactive": "Inactive"
}, },
"ai": { "ai": {
"title": "AI Assistant", "title": "AI Assistant",
+5 -1
View File
@@ -1,5 +1,7 @@
import { useState } from "react"; import { useState } from "react";
import { useAuth } from "@/context/AuthContext"; import { useAuth } from "@/context/AuthContext";
import { useTranslation } from "react-i18next";
import { useTheme } from "@/context/ThemeContext";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -7,9 +9,11 @@ import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import Sidebar from "@/components/layout/Sidebar"; import Sidebar from "@/components/layout/Sidebar";
import { User, Bell, Shield, Palette } from "lucide-react"; import { User, Bell, Shield, Palette, Moon, Sun, Globe, Download, Trash2 } from "lucide-react";
export default function SettingsPage() { export default function SettingsPage() {
const { user, updateProfile } = useAuth(); const { user, updateProfile } = useAuth();