feat: toast notifications + global search (Cmd+K)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-12 11:19:45 +03:00
parent 5db7f0c428
commit 066007e72d
4 changed files with 267 additions and 1 deletions
+50 -1
View File
@@ -1,10 +1,13 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Outlet, useLocation } from 'react-router-dom'; 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 Sidebar from './Sidebar';
import { supabase } from '../../lib/supabase'; import { supabase } from '../../lib/supabase';
import { useIsMobile } from '../../hooks/useIsMobile'; import { useIsMobile } from '../../hooks/useIsMobile';
import { usePushNotifications } from '../../hooks/usePushNotifications'; import { usePushNotifications } from '../../hooks/usePushNotifications';
import { ToastProvider } from '../../contexts/ToastContext';
import { SearchModal } from '../search/SearchModal';
import OrderModal from '../orders/OrderModal';
const PAGE_TITLES = { const PAGE_TITLES = {
'/admin/kanban': 'Канбан', '/admin/kanban': 'Канбан',
@@ -18,6 +21,8 @@ const AdminLayout = () => {
const [showNewOrder, setShowNewOrder] = useState(false); const [showNewOrder, setShowNewOrder] = useState(false);
const [newCount, setNewCount] = useState(0); const [newCount, setNewCount] = useState(0);
const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false);
const [searchOpen, setSearchOpen] = useState(false);
const [searchOrder, setSearchOrder] = useState(null);
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const { supported: pushSupported, subscribed: pushSubscribed, loading: pushLoading, subscribe: pushSubscribe, unsubscribe: pushUnsubscribe } = usePushNotifications(); const { supported: pushSupported, subscribed: pushSubscribed, loading: pushLoading, subscribe: pushSubscribe, unsubscribe: pushUnsubscribe } = usePushNotifications();
@@ -42,7 +47,19 @@ const AdminLayout = () => {
setSidebarOpen(false); setSidebarOpen(false);
}, [location.pathname]); }, [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 ( return (
<ToastProvider>
<div className="admin-panel" style={{ display: 'flex', minHeight: '100vh' }}> <div className="admin-panel" style={{ display: 'flex', minHeight: '100vh' }}>
{isMobile && sidebarOpen && ( {isMobile && sidebarOpen && (
<div <div
@@ -123,6 +140,23 @@ const AdminLayout = () => {
{pushSubscribed ? <Bell size={16} /> : <BellOff size={16} />} {pushSubscribed ? <Bell size={16} /> : <BellOff size={16} />}
</button> </button>
)} )}
<button
onClick={() => setSearchOpen(true)}
title="Поиск (⌘K)"
style={{
background: 'none', border: '1.5px solid var(--border-light)',
borderRadius: '8px', padding: '7px 12px',
cursor: 'pointer', color: 'var(--text-muted)',
display: 'flex', alignItems: 'center', gap: '6px',
fontSize: '0.78rem', fontFamily: 'var(--font-body)',
transition: 'border-color 0.15s, color 0.15s',
}}
onMouseEnter={(e) => { e.currentTarget.style.borderColor = '#4361EE'; e.currentTarget.style.color = '#4361EE'; }}
onMouseLeave={(e) => { e.currentTarget.style.borderColor = 'var(--border-light)'; e.currentTarget.style.color = 'var(--text-muted)'; }}
>
<Search size={15} />
{!isMobile && <span>Поиск</span>}
</button>
<button <button
onClick={() => setShowNewOrder(true)} onClick={() => setShowNewOrder(true)}
className="btn-primary" className="btn-primary"
@@ -140,6 +174,21 @@ const AdminLayout = () => {
</main> </main>
</div> </div>
</div> </div>
{searchOpen && (
<SearchModal
onClose={() => setSearchOpen(false)}
onOpenOrder={(order) => setSearchOrder(order)}
/>
)}
{searchOrder && (
<OrderModal
order={searchOrder}
mode="edit"
onClose={() => setSearchOrder(null)}
onSaved={() => setSearchOrder(null)}
/>
)}
</ToastProvider>
); );
}; };
+135
View File
@@ -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 (
<div
onClick={onClose}
style={{
position: 'fixed', inset: 0, zIndex: 2000,
background: 'rgba(0,0,0,0.45)',
display: 'flex', alignItems: 'flex-start', justifyContent: 'center',
paddingTop: '80px', padding: '80px 16px 16px',
}}
>
<div
onClick={(e) => 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 */}
<div style={{ display: 'flex', alignItems: 'center', padding: '16px 20px', borderBottom: '1px solid #E9ECEF', gap: '12px' }}>
<Search size={18} style={{ color: '#8C9097', flexShrink: 0 }} />
<input
ref={inputRef}
value={query}
onChange={(e) => { 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)',
}}
/>
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#8C9097', padding: '2px' }}>
<X size={18} />
</button>
</div>
{/* Results */}
<div style={{ maxHeight: '400px', overflowY: 'auto' }}>
{loading && (
<div style={{ padding: '20px', textAlign: 'center', color: '#8C9097', fontSize: '0.85rem' }}>Поиск...</div>
)}
{!loading && query && results.length === 0 && (
<div style={{ padding: '20px', textAlign: 'center', color: '#8C9097', fontSize: '0.85rem' }}>Ничего не найдено</div>
)}
{!loading && !query && (
<div style={{ padding: '20px', textAlign: 'center', color: '#8C9097', fontSize: '0.85rem' }}>
Начните вводить для поиска
</div>
)}
{results.map((o) => {
const color = STATUS_COLORS[o.status] || '#8C9097';
return (
<div
key={o.id}
onClick={() => { 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 = ''}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600, fontSize: '0.92rem', marginBottom: '2px' }}>{o.name}</div>
<div style={{ fontSize: '0.75rem', color: '#8C9097' }}>
{o.phone}
{o.final_cost ? ` · ${Number(o.final_cost).toLocaleString('ru-RU')}` : ''}
</div>
</div>
<span style={{
background: color + '1A', color,
fontSize: '0.7rem', fontWeight: 700,
padding: '3px 8px', borderRadius: '10px',
textTransform: 'uppercase', letterSpacing: '0.3px', flexShrink: 0,
}}>
{o.status}
</span>
</div>
);
})}
</div>
{/* Footer hint */}
<div style={{ padding: '10px 20px', borderTop: '1px solid #F3F4F6', fontSize: '0.72rem', color: '#8C9097', display: 'flex', gap: '12px' }}>
<span> открыть</span>
<span>Esc закрыть</span>
</div>
</div>
</div>
);
}
+77
View File
@@ -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 (
<ToastContext.Provider value={{ addToast }}>
{children}
<ToastContainer toasts={toasts} onRemove={removeToast} />
</ToastContext.Provider>
);
}
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 (
<div style={{
position: 'fixed', bottom: '24px', right: '24px',
zIndex: 9999, display: 'flex', flexDirection: 'column', gap: '8px',
pointerEvents: 'none',
}}>
{toasts.map((t) => {
const s = TYPE_STYLES[t.type] || TYPE_STYLES.success;
return (
<div
key={t.id}
onClick={() => 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)',
}}
>
<span style={{
width: '20px', height: '20px', borderRadius: '50%',
background: 'rgba(255,255,255,0.25)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: '0.75rem', fontWeight: 800, flexShrink: 0,
}}>
{s.icon}
</span>
{t.message}
</div>
);
})}
</div>
);
}
+5
View File
@@ -164,3 +164,8 @@ section {
border-radius: 8px; border-radius: 8px;
border-color: #E9ECEF; border-color: #E9ECEF;
} }
@keyframes slideInRight {
from { transform: translateX(40px); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}