diff --git a/salesflow-saas/frontend/src/components/dealix/command-palette.tsx b/salesflow-saas/frontend/src/components/dealix/command-palette.tsx new file mode 100644 index 00000000..d0b1577e --- /dev/null +++ b/salesflow-saas/frontend/src/components/dealix/command-palette.tsx @@ -0,0 +1,289 @@ +'use client'; + +import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { clsx } from 'clsx'; +import { + Search, Plus, MessageSquare, BarChart3, Settings, + Users, Briefcase, ArrowRight, Clock, Inbox, + LayoutDashboard, UserPlus, CheckSquare, Megaphone, +} from 'lucide-react'; +import { useI18n } from '@/i18n'; + +type CommandCategory = 'recent' | 'navigation' | 'actions' | 'contacts' | 'deals'; + +interface CommandItem { + id: string; + label: string; + labelAr: string; + category: CommandCategory; + icon: typeof Search; + keywords: string[]; + onSelect?: () => void; +} + +interface CommandPaletteProps { + open: boolean; + onClose: () => void; + onSelect?: (item: CommandItem) => void; +} + +const backdropVariants = { + hidden: { opacity: 0 }, + visible: { opacity: 1 }, +}; + +const panelVariants = { + hidden: { opacity: 0, scale: 0.96, y: -8 }, + visible: { opacity: 1, scale: 1, y: 0 }, + exit: { opacity: 0, scale: 0.96, y: -8 }, +}; + +function buildItems(t: (k: string) => string): CommandItem[] { + return [ + { id: 'nav-dashboard', label: 'Dashboard', labelAr: t('dashboard.tabs.overview'), category: 'navigation', icon: LayoutDashboard, keywords: ['home', 'لوحة', 'loha', 'dashboard'] }, + { id: 'nav-pipeline', label: 'Pipeline', labelAr: t('dashboard.tabs.pipeline'), category: 'navigation', icon: Briefcase, keywords: ['deals', 'مسار', 'masar', 'pipeline', 'صفقات'] }, + { id: 'nav-inbox', label: 'Inbox', labelAr: t('dashboard.tabs.inbox'), category: 'navigation', icon: Inbox, keywords: ['messages', 'صندوق', 'sandoq', 'inbox', 'رسائل'] }, + { id: 'nav-analytics', label: 'Analytics', labelAr: t('dashboard.tabs.analytics'), category: 'navigation', icon: BarChart3, keywords: ['reports', 'تحليلات', 'tahlilat', 'analytics', 'تقارير'] }, + { id: 'nav-leads', label: 'Leads', labelAr: t('dashboard.tabs.leads'), category: 'navigation', icon: Users, keywords: ['clients', 'عملاء', '3omala', 'leads'] }, + { id: 'nav-settings', label: 'Settings', labelAr: t('dashboard.tabs.settings'), category: 'navigation', icon: Settings, keywords: ['config', 'إعدادات', 'e3dadat', 'settings'] }, + { id: 'nav-marketers', label: 'Marketers', labelAr: t('commandPalette.actions.goToMarketers'), category: 'navigation', icon: Megaphone, keywords: ['affiliate', 'مسوقين', 'msawqin', 'marketers'] }, + { id: 'act-new-deal', label: 'Create New Deal', labelAr: t('commandPalette.actions.newDeal'), category: 'actions', icon: Plus, keywords: ['new', 'deal', 'صفقة', 'safqa', 'جديد', 'jadid', 'create'] }, + { id: 'act-new-contact', label: 'Add Contact', labelAr: t('commandPalette.actions.newContact'), category: 'actions', icon: UserPlus, keywords: ['contact', 'add', 'إضافة', 'edafa', 'جهة', 'jiha'] }, + { id: 'act-new-task', label: 'Create Task', labelAr: t('commandPalette.actions.newTask'), category: 'actions', icon: CheckSquare, keywords: ['task', 'مهمة', 'muhimma', 'todo'] }, + { id: 'act-send-msg', label: 'Send Message', labelAr: t('commandPalette.actions.sendMessage'), category: 'actions', icon: MessageSquare, keywords: ['message', 'رسالة', 'risala', 'whatsapp', 'واتساب'] }, + ]; +} + +function fuzzyMatch(query: string, item: CommandItem, isArabic: boolean): boolean { + const q = query.toLowerCase(); + const haystack = [ + item.label.toLowerCase(), + item.labelAr, + ...item.keywords.map((k) => k.toLowerCase()), + ].join(' '); + return haystack.includes(q); +} + +function CommandPalette({ open, onClose, onSelect }: CommandPaletteProps) { + const { t, dir, isArabic } = useI18n(); + const [query, setQuery] = useState(''); + const [activeIndex, setActiveIndex] = useState(0); + const inputRef = useRef(null); + const listRef = useRef(null); + + const allItems = useMemo(() => buildItems(t), [t]); + + const filtered = useMemo(() => { + if (!query.trim()) return allItems.slice(0, 8); + return allItems.filter((item) => fuzzyMatch(query, item, isArabic)); + }, [query, allItems, isArabic]); + + const grouped = useMemo(() => { + const map = new Map(); + for (const item of filtered) { + const list = map.get(item.category) ?? []; + list.push(item); + map.set(item.category, list); + } + return map; + }, [filtered]); + + const flatItems = useMemo(() => filtered, [filtered]); + + useEffect(() => { + if (open) { + setQuery(''); + setActiveIndex(0); + setTimeout(() => inputRef.current?.focus(), 50); + } + }, [open]); + + useEffect(() => { + setActiveIndex(0); + }, [query]); + + const handleSelect = useCallback( + (item: CommandItem) => { + onSelect?.(item); + item.onSelect?.(); + onClose(); + }, + [onSelect, onClose], + ); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'ArrowDown') { + e.preventDefault(); + setActiveIndex((i) => (i + 1) % flatItems.length); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setActiveIndex((i) => (i - 1 + flatItems.length) % flatItems.length); + } else if (e.key === 'Enter' && flatItems[activeIndex]) { + e.preventDefault(); + handleSelect(flatItems[activeIndex]); + } else if (e.key === 'Escape') { + e.preventDefault(); + onClose(); + } + }, + [flatItems, activeIndex, handleSelect, onClose], + ); + + useEffect(() => { + if (!open) return; + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [open, onClose]); + + const categoryLabel = (cat: CommandCategory) => + t(`commandPalette.categories.${cat}`); + + return ( + + {open && ( +
+
+ )} +
+ ); +} + +function useCommandPalette() { + const [open, setOpen] = useState(false); + + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'k') { + e.preventDefault(); + setOpen((prev) => !prev); + } + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, []); + + return { open, setOpen, onClose: () => setOpen(false) }; +} + +export { CommandPalette, useCommandPalette }; +export type { CommandPaletteProps, CommandItem }; diff --git a/salesflow-saas/frontend/src/components/dealix/sales-workspace.tsx b/salesflow-saas/frontend/src/components/dealix/sales-workspace.tsx new file mode 100644 index 00000000..14752e24 --- /dev/null +++ b/salesflow-saas/frontend/src/components/dealix/sales-workspace.tsx @@ -0,0 +1,364 @@ +'use client'; + +import { useMemo } from 'react'; +import { motion } from 'framer-motion'; +import { clsx } from 'clsx'; +import { + Users, CalendarPlus, Briefcase, TrendingUp, Clock, Zap, + CheckCircle2, Circle, AlertTriangle, MessageSquare, Phone, + ArrowUpRight, Sparkles, ChevronLeft, ChevronRight, + FileText, InboxIcon, +} from 'lucide-react'; +import { useI18n } from '@/i18n'; +import { KpiCard } from '@/components/ui/kpi-card'; +import { EmptyState } from '@/components/ui/empty-state'; + +/* ---------- Types ---------- */ +interface Task { + id: string; + title: string; + dueStatus: 'overdue' | 'today' | 'upcoming'; + time?: string; +} + +interface Deal { + id: string; + name: string; + value: number; + stage: string; + stageColor: string; +} + +interface Activity { + id: string; + type: 'message' | 'call' | 'dealUpdate' | 'noteAdded'; + text: string; + time: string; +} + +interface AiInsight { + id: string; + type: 'followUp' | 'closing' | 'risk'; + count: number; +} + +interface SalesWorkspaceProps { + userName?: string; + kpis?: { + totalLeads: number; + newToday: number; + openDeals: number; + wonValue: number; + conversionRate: number; + responseTime: number; + }; + tasks?: Task[]; + deals?: Deal[]; + activities?: Activity[]; + insights?: AiInsight[]; + className?: string; +} + +/* ---------- Demo data ---------- */ +const demoKpis = { + totalLeads: 1247, + newToday: 18, + openDeals: 43, + wonValue: 892500, + conversionRate: 34, + responseTime: 12, +}; + +const demoTasks: Task[] = [ + { id: '1', title: 'متابعة أحمد الشمري — عرض عقار', dueStatus: 'overdue', time: 'أمس' }, + { id: '2', title: 'اتصال مع نورة — عرض سعر', dueStatus: 'today', time: '2:00 م' }, + { id: '3', title: 'إرسال عقد لشركة المستقبل', dueStatus: 'today', time: '4:30 م' }, + { id: '4', title: 'جدولة عرض تقديمي', dueStatus: 'upcoming', time: 'غداً' }, +]; + +const demoDeals: Deal[] = [ + { id: '1', name: 'صفقة أبراج الرياض', value: 2500000, stage: 'تفاوض', stageColor: 'bg-amber-500' }, + { id: '2', name: 'مشروع المجمع التجاري', value: 1800000, stage: 'عرض سعر', stageColor: 'bg-teal-500' }, + { id: '3', name: 'فيلا حي النرجس', value: 950000, stage: 'مؤهّل', stageColor: 'bg-blue-500' }, + { id: '4', name: 'مكاتب طريق الملك', value: 780000, stage: 'تفاوض', stageColor: 'bg-amber-500' }, + { id: '5', name: 'شقق حي الملقا', value: 650000, stage: 'عرض سعر', stageColor: 'bg-teal-500' }, +]; + +const demoActivities: Activity[] = [ + { id: '1', type: 'message', text: 'رسالة من أحمد: "ابي تفاصيل العرض"', time: 'منذ 5 دقائق' }, + { id: '2', type: 'call', text: 'مكالمة مع نورة — 8 دقائق', time: 'منذ 30 دقيقة' }, + { id: '3', type: 'dealUpdate', text: 'صفقة أبراج الرياض انتقلت لمرحلة التفاوض', time: 'منذ ساعة' }, + { id: '4', type: 'noteAdded', text: 'ملاحظة على فيلا النرجس: العميل يبي جراج إضافي', time: 'منذ 2 ساعة' }, +]; + +const demoInsights: AiInsight[] = [ + { id: '1', type: 'followUp', count: 3 }, + { id: '2', type: 'closing', count: 2 }, + { id: '3', type: 'risk', count: 1 }, +]; + +/* ---------- Sub-components ---------- */ +const stagger = { + hidden: {}, + visible: { transition: { staggerChildren: 0.06 } }, +}; + +const fadeUp = { + hidden: { opacity: 0, y: 12 }, + visible: { opacity: 1, y: 0, transition: { duration: 0.35 } }, +}; + +function GlassCard({ children, className }: { children: React.ReactNode; className?: string }) { + return ( + + {children} + + ); +} + +function SectionHeader({ icon: Icon, title }: { icon: typeof Users; title: string }) { + return ( +
+ +

{title}

+
+ ); +} + +const activityIcons: Record = { + message: MessageSquare, + call: Phone, + dealUpdate: ArrowUpRight, + noteAdded: FileText, +}; + +const taskStatusStyles: Record = { + overdue: { dot: 'bg-rose-500', text: 'text-rose-400' }, + today: { dot: 'bg-amber-500', text: 'text-amber-400' }, + upcoming: { dot: 'bg-slate-500', text: 'text-slate-400' }, +}; + +const insightIcons: Record = { + followUp: { icon: Clock, color: 'text-amber-400' }, + closing: { icon: TrendingUp, color: 'text-emerald-400' }, + risk: { icon: AlertTriangle, color: 'text-rose-400' }, +}; + +/* ---------- Main ---------- */ +function SalesWorkspace({ + userName, + kpis: kpisProp, + tasks: tasksProp, + deals: dealsProp, + activities: activitiesProp, + insights: insightsProp, + className, +}: SalesWorkspaceProps) { + const { t, dir, locale, isArabic } = useI18n(); + + const kpis = kpisProp ?? demoKpis; + const tasks = tasksProp ?? demoTasks; + const deals = dealsProp ?? demoDeals; + const activities = activitiesProp ?? demoActivities; + const insights = insightsProp ?? demoInsights; + + const greeting = useMemo(() => { + const hour = new Date().getHours(); + const base = hour < 17 ? t('workspace.greeting') : t('workspace.greetingEvening'); + return userName ? `${base}، ${userName}` : base; + }, [t, userName]); + + const formatCurrency = (val: number) => + new Intl.NumberFormat(locale === 'ar' ? 'ar-SA' : 'en-US', { + style: 'currency', + currency: 'SAR', + maximumFractionDigits: 0, + }).format(val); + + const kpiDefs = [ + { key: 'totalLeads', value: kpis.totalLeads, label: t('dashboard.kpis.totalLeads'), icon: Users, trend: { direction: 'up' as const, percentage: 12 }, sparkline: [30, 42, 38, 55, 52, 68, 62] }, + { key: 'newToday', value: kpis.newToday, label: t('dashboard.kpis.newToday'), icon: CalendarPlus, trend: { direction: 'up' as const, percentage: 8 }, sparkline: [5, 8, 12, 9, 15, 11, 18] }, + { key: 'openDeals', value: kpis.openDeals, label: t('dashboard.kpis.openDeals'), icon: Briefcase, trend: { direction: 'up' as const, percentage: 5 }, sparkline: [28, 35, 31, 40, 38, 42, 43] }, + { key: 'wonValue', value: kpis.wonValue, label: t('dashboard.kpis.wonValue'), prefix: isArabic ? 'ر.س' : 'SAR', trend: { direction: 'up' as const, percentage: 22 }, sparkline: [400, 520, 480, 650, 720, 810, 892] }, + { key: 'conversionRate', value: kpis.conversionRate, label: t('dashboard.kpis.conversionRate'), suffix: '%', trend: { direction: 'down' as const, percentage: 3 }, sparkline: [38, 36, 35, 37, 34, 33, 34] }, + { key: 'responseTime', value: kpis.responseTime, label: t('dashboard.kpis.responseTime'), suffix: t('workspace.kpiResponseUnit'), trend: { direction: 'up' as const, percentage: 15 }, sparkline: [20, 18, 15, 14, 13, 12, 12] }, + ]; + + const insightLabel = (i: AiInsight) => { + const labels: Record = { + followUp: t('workspace.aiInsightFollowUp'), + closing: t('workspace.aiInsightClosing'), + risk: t('workspace.aiInsightRisk'), + }; + return `${i.count} ${labels[i.type]}`; + }; + + return ( + + {/* Greeting */} + + {greeting} + + + {/* KPI Bar */} + + {kpiDefs.map((k) => ( + + ))} + + + {/* 3-column body */} +
+ {/* LEFT: Tasks */} + + + {tasks.length === 0 ? ( + + ) : ( +
    + {tasks.map((task) => { + const style = taskStatusStyles[task.dueStatus]; + return ( +
  • + +
    +

    {task.title}

    +

    {task.time}

    +
    +
  • + ); + })} +
+ )} +
+ + {/* CENTER: Hot Deals */} + + + {deals.length === 0 ? ( + + ) : ( +
+ {deals.map((deal, idx) => ( +
+ + {idx + 1} + +
+

{deal.name}

+
+ + {deal.stage} +
+
+ + {formatCurrency(deal.value)} + +
+ ))} +
+ )} +
+ + {/* RIGHT: Activity */} + + + {activities.length === 0 ? ( + + ) : ( +
    + {activities.map((act) => { + const Icon = activityIcons[act.type]; + return ( +
  • +
    + +
    +
    +

    {act.text}

    +

    {act.time}

    +
    +
  • + ); + })} +
+ )} +
+
+ + {/* AI Insights */} + + +
+ {insights.map((insight) => { + const { icon: Icon, color } = insightIcons[insight.type]; + return ( + + + {insightLabel(insight)} + {isArabic ? ( + + ) : ( + + )} + + ); + })} +
+
+
+ ); +} + +export { SalesWorkspace }; +export type { SalesWorkspaceProps, Task, Deal, Activity, AiInsight }; diff --git a/salesflow-saas/frontend/src/components/ui/command-input.tsx b/salesflow-saas/frontend/src/components/ui/command-input.tsx new file mode 100644 index 00000000..857fa520 --- /dev/null +++ b/salesflow-saas/frontend/src/components/ui/command-input.tsx @@ -0,0 +1,60 @@ +'use client'; + +import { forwardRef, type InputHTMLAttributes } from 'react'; +import { clsx } from 'clsx'; +import { Search } from 'lucide-react'; +import { useI18n } from '@/i18n'; + +interface CommandInputProps extends Omit, 'type'> { + onCommandClick?: () => void; +} + +const CommandInput = forwardRef( + ({ onCommandClick, className, placeholder, ...props }, ref) => { + const { t, dir } = useI18n(); + + const resolvedPlaceholder = placeholder ?? t('commandPalette.placeholder'); + + return ( + + ); + }, +); + +CommandInput.displayName = 'CommandInput'; + +export { CommandInput }; +export type { CommandInputProps }; diff --git a/salesflow-saas/frontend/src/components/ui/empty-state.tsx b/salesflow-saas/frontend/src/components/ui/empty-state.tsx new file mode 100644 index 00000000..c3de80b2 --- /dev/null +++ b/salesflow-saas/frontend/src/components/ui/empty-state.tsx @@ -0,0 +1,75 @@ +'use client'; + +import { type ReactNode } from 'react'; +import { motion } from 'framer-motion'; +import { clsx } from 'clsx'; +import { useI18n } from '@/i18n'; +import { type LucideIcon } from 'lucide-react'; + +interface EmptyStateProps { + icon: LucideIcon; + title: string; + description?: string; + actionLabel?: string; + onAction?: () => void; + className?: string; +} + +function EmptyState({ + icon: Icon, + title, + description, + actionLabel, + onAction, + className, +}: EmptyStateProps) { + const { dir } = useI18n(); + + return ( + +
+ +
+ +

+ {title} +

+ + {description && ( +

+ {description} +

+ )} + + {actionLabel && onAction && ( + + {actionLabel} + + )} +
+ ); +} + +export { EmptyState }; +export type { EmptyStateProps }; diff --git a/salesflow-saas/frontend/src/components/ui/kpi-card.tsx b/salesflow-saas/frontend/src/components/ui/kpi-card.tsx new file mode 100644 index 00000000..77947527 --- /dev/null +++ b/salesflow-saas/frontend/src/components/ui/kpi-card.tsx @@ -0,0 +1,162 @@ +'use client'; + +import { useEffect, useRef, useState, useMemo } from 'react'; +import { motion } from 'framer-motion'; +import { clsx } from 'clsx'; +import { TrendingUp, TrendingDown } from 'lucide-react'; +import { useI18n } from '@/i18n'; + +interface KpiCardProps { + value: number; + label: string; + trend?: { direction: 'up' | 'down'; percentage: number }; + prefix?: string; + suffix?: string; + sparklineData?: number[]; + variant?: 'compact' | 'full'; + className?: string; +} + +function useCountUp(target: number, duration: number = 1200) { + const [current, setCurrent] = useState(0); + const frameRef = useRef(); + + useEffect(() => { + const start = performance.now(); + const animate = (now: number) => { + const elapsed = now - start; + const progress = Math.min(elapsed / duration, 1); + const eased = 1 - Math.pow(1 - progress, 3); + setCurrent(Math.round(target * eased)); + if (progress < 1) { + frameRef.current = requestAnimationFrame(animate); + } + }; + frameRef.current = requestAnimationFrame(animate); + return () => { + if (frameRef.current) cancelAnimationFrame(frameRef.current); + }; + }, [target, duration]); + + return current; +} + +function Sparkline({ data, className }: { data: number[]; className?: string }) { + const max = Math.max(...data); + const min = Math.min(...data); + const range = max - min || 1; + const h = 28; + const w = 72; + const step = w / (data.length - 1); + + const points = data + .map((v, i) => `${i * step},${h - ((v - min) / range) * h}`) + .join(' '); + + return ( + + + + ); +} + +function KpiCard({ + value, + label, + trend, + prefix, + suffix, + sparklineData, + variant = 'full', + className, +}: KpiCardProps) { + const { locale } = useI18n(); + const animatedValue = useCountUp(value); + + const formatted = useMemo(() => { + return new Intl.NumberFormat(locale === 'ar' ? 'ar-SA' : 'en-US').format( + animatedValue, + ); + }, [animatedValue, locale]); + + const isCompact = variant === 'compact'; + + return ( + +
+
+

+ {label} +

+

+ {prefix && {prefix}} + {formatted} + {suffix && {suffix}} +

+
+ + {sparklineData && sparklineData.length > 1 && ( +
+ +
+ )} +
+ + {trend && ( +
+ {trend.direction === 'up' ? ( + + ) : ( + + )} + + {trend.percentage}% + +
+ )} +
+ ); +} + +export { KpiCard }; +export type { KpiCardProps };