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; }
+}