diff --git a/src/admin/layout/AdminLayout.jsx b/src/admin/layout/AdminLayout.jsx index 0f85609..700a5db 100644 --- a/src/admin/layout/AdminLayout.jsx +++ b/src/admin/layout/AdminLayout.jsx @@ -1,10 +1,13 @@ import { useState, useEffect } from 'react'; import { Outlet, useLocation } from 'react-router-dom'; -import { Plus, Menu, Bell, BellOff } from 'lucide-react'; +import { Plus, Menu, Bell, BellOff, Search } from 'lucide-react'; import Sidebar from './Sidebar'; import { supabase } from '../../lib/supabase'; import { useIsMobile } from '../../hooks/useIsMobile'; import { usePushNotifications } from '../../hooks/usePushNotifications'; +import { ToastProvider } from '../../contexts/ToastContext'; +import { SearchModal } from '../search/SearchModal'; +import OrderModal from '../orders/OrderModal'; const PAGE_TITLES = { '/admin/kanban': 'Канбан', @@ -18,6 +21,8 @@ const AdminLayout = () => { const [showNewOrder, setShowNewOrder] = useState(false); const [newCount, setNewCount] = useState(0); const [sidebarOpen, setSidebarOpen] = useState(false); + const [searchOpen, setSearchOpen] = useState(false); + const [searchOrder, setSearchOrder] = useState(null); const isMobile = useIsMobile(); const { supported: pushSupported, subscribed: pushSubscribed, loading: pushLoading, subscribe: pushSubscribe, unsubscribe: pushUnsubscribe } = usePushNotifications(); @@ -42,7 +47,19 @@ const AdminLayout = () => { setSidebarOpen(false); }, [location.pathname]); + useEffect(() => { + const handler = (e) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'k') { + e.preventDefault(); + setSearchOpen(true); + } + }; + document.addEventListener('keydown', handler); + return () => document.removeEventListener('keydown', handler); + }, []); + return ( +
{isMobile && sidebarOpen && (
{ {pushSubscribed ? : } )} +
+ {searchOpen && ( + setSearchOpen(false)} + onOpenOrder={(order) => setSearchOrder(order)} + /> + )} + {searchOrder && ( + setSearchOrder(null)} + onSaved={() => setSearchOrder(null)} + /> + )} +
); }; diff --git a/src/admin/search/SearchModal.jsx b/src/admin/search/SearchModal.jsx new file mode 100644 index 0000000..a930876 --- /dev/null +++ b/src/admin/search/SearchModal.jsx @@ -0,0 +1,135 @@ +import { useState, useEffect, useRef } from 'react'; +import { Search, X } from 'lucide-react'; +import { supabase } from '../../lib/supabase'; + +const STATUS_COLORS = { + 'новая': '#8C9097', 'замер': '#F59E0B', 'согласование': '#8b5cf6', + 'монтаж': '#4361EE', 'закрыт': '#22C55E', +}; + +export function SearchModal({ onClose, onOpenOrder }) { + const [query, setQuery] = useState(''); + const [results, setResults] = useState([]); + const [loading, setLoading] = useState(false); + const inputRef = useRef(null); + const debounceRef = useRef(null); + + useEffect(() => { inputRef.current?.focus(); }, []); + + // Close on Escape + useEffect(() => { + const handler = (e) => { if (e.key === 'Escape') onClose(); }; + document.addEventListener('keydown', handler); + return () => document.removeEventListener('keydown', handler); + }, [onClose]); + + const search = (q) => { + clearTimeout(debounceRef.current); + if (!q.trim()) { setResults([]); return; } + debounceRef.current = setTimeout(async () => { + setLoading(true); + const { data } = await supabase + .from('orders') + .select('id, name, phone, status, final_cost, created_at') + .or(`name.ilike.%${q}%,phone.ilike.%${q}%,address.ilike.%${q}%`) + .order('created_at', { ascending: false }) + .limit(15); + setResults(data ?? []); + setLoading(false); + }, 250); + }; + + return ( +
+
e.stopPropagation()} + style={{ + background: '#fff', borderRadius: '16px', + width: '100%', maxWidth: '560px', + boxShadow: '0 16px 48px rgba(0,0,0,0.2)', + overflow: 'hidden', + }} + > + {/* Search input */} +
+ + { setQuery(e.target.value); search(e.target.value); }} + placeholder="Поиск по имени, телефону, адресу..." + style={{ + flex: 1, border: 'none', outline: 'none', + fontSize: '1rem', fontFamily: 'var(--font-body)', + color: 'var(--text-main)', + }} + /> + +
+ + {/* Results */} +
+ {loading && ( +
Поиск...
+ )} + {!loading && query && results.length === 0 && ( +
Ничего не найдено
+ )} + {!loading && !query && ( +
+ Начните вводить для поиска +
+ )} + {results.map((o) => { + const color = STATUS_COLORS[o.status] || '#8C9097'; + return ( +
{ onOpenOrder(o); onClose(); }} + style={{ + padding: '12px 20px', display: 'flex', alignItems: 'center', + gap: '12px', cursor: 'pointer', borderBottom: '1px solid #F3F4F6', + transition: 'background 0.1s', + }} + onMouseEnter={(e) => e.currentTarget.style.background = '#F5F7FA'} + onMouseLeave={(e) => e.currentTarget.style.background = ''} + > +
+
{o.name}
+
+ {o.phone} + {o.final_cost ? ` · ${Number(o.final_cost).toLocaleString('ru-RU')} ₽` : ''} +
+
+ + {o.status} + +
+ ); + })} +
+ + {/* Footer hint */} +
+ ↵ открыть + Esc закрыть +
+
+
+ ); +} diff --git a/src/contexts/ToastContext.jsx b/src/contexts/ToastContext.jsx new file mode 100644 index 0000000..4f9b615 --- /dev/null +++ b/src/contexts/ToastContext.jsx @@ -0,0 +1,77 @@ +import { createContext, useContext, useState, useCallback } from 'react'; + +const ToastContext = createContext(null); + +export function ToastProvider({ children }) { + const [toasts, setToasts] = useState([]); + + const addToast = useCallback((message, type = 'success') => { + const id = Date.now() + Math.random(); + setToasts((prev) => [...prev, { id, message, type }]); + setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), 3000); + }, []); + + const removeToast = useCallback((id) => { + setToasts((prev) => prev.filter((t) => t.id !== id)); + }, []); + + return ( + + {children} + + + ); +} + +export function useToast() { + const ctx = useContext(ToastContext); + if (!ctx) throw new Error('useToast must be used within ToastProvider'); + return ctx; +} + +const TYPE_STYLES = { + success: { background: '#10b981', icon: '✓' }, + error: { background: '#ef4444', icon: '✕' }, + info: { background: '#4361EE', icon: 'i' }, +}; + +function ToastContainer({ toasts, onRemove }) { + if (!toasts.length) return null; + return ( +
+ {toasts.map((t) => { + const s = TYPE_STYLES[t.type] || TYPE_STYLES.success; + return ( +
onRemove(t.id)} + style={{ + background: s.background, color: '#fff', + padding: '12px 16px', borderRadius: '10px', + fontSize: '0.88rem', fontWeight: 600, + boxShadow: '0 4px 16px rgba(0,0,0,0.18)', + display: 'flex', alignItems: 'center', gap: '10px', + maxWidth: '320px', pointerEvents: 'all', cursor: 'pointer', + animation: 'slideInRight 0.2s ease', + fontFamily: 'var(--font-body)', + }} + > + + {s.icon} + + {t.message} +
+ ); + })} +
+ ); +} diff --git a/src/index.css b/src/index.css index db74b6a..c201445 100644 --- a/src/index.css +++ b/src/index.css @@ -164,3 +164,8 @@ section { border-radius: 8px; border-color: #E9ECEF; } + +@keyframes slideInRight { + from { transform: translateX(40px); opacity: 0; } + to { transform: translateX(0); opacity: 1; } +}