feat: toast notifications + global search (Cmd+K)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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 (
|
||||
<ToastProvider>
|
||||
<div className="admin-panel" style={{ display: 'flex', minHeight: '100vh' }}>
|
||||
{isMobile && sidebarOpen && (
|
||||
<div
|
||||
@@ -123,6 +140,23 @@ const AdminLayout = () => {
|
||||
{pushSubscribed ? <Bell size={16} /> : <BellOff size={16} />}
|
||||
</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
|
||||
onClick={() => setShowNewOrder(true)}
|
||||
className="btn-primary"
|
||||
@@ -140,6 +174,21 @@ const AdminLayout = () => {
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
{searchOpen && (
|
||||
<SearchModal
|
||||
onClose={() => setSearchOpen(false)}
|
||||
onOpenOrder={(order) => setSearchOrder(order)}
|
||||
/>
|
||||
)}
|
||||
{searchOrder && (
|
||||
<OrderModal
|
||||
order={searchOrder}
|
||||
mode="edit"
|
||||
onClose={() => setSearchOrder(null)}
|
||||
onSaved={() => setSearchOrder(null)}
|
||||
/>
|
||||
)}
|
||||
</ToastProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user