feat: push notifications, calendar view, photo attachments
- Push notifications: custom SW (injectManifest), usePushNotifications hook, push-notify Edge Function, push_subscriptions migration, bell toggle in header - Calendar view: month-grid CalendarPage + useCalendar hook, scheduled_at column migration, datetime field in OrderModal, CalendarDays nav item + route - Photo attachments: useOrderPhotos hook, photo gallery/upload in OrderModal (edit mode), order-photos Storage bucket migration with RLS policies Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -19,6 +19,7 @@ const CompletedOrdersTable = React.lazy(() => import('./admin/analytics/Complete
|
|||||||
const UserManagementPage = React.lazy(() => import('./admin/users/UserManagementPage'));
|
const UserManagementPage = React.lazy(() => import('./admin/users/UserManagementPage'));
|
||||||
const SettingsPage = React.lazy(() => import('./admin/settings/SettingsPage'));
|
const SettingsPage = React.lazy(() => import('./admin/settings/SettingsPage'));
|
||||||
const ClientsPage = React.lazy(() => import('./admin/clients/ClientsPage'));
|
const ClientsPage = React.lazy(() => import('./admin/clients/ClientsPage'));
|
||||||
|
const CalendarPage = React.lazy(() => import('./admin/calendar/CalendarPage'));
|
||||||
|
|
||||||
function PublicSite() {
|
function PublicSite() {
|
||||||
return (
|
return (
|
||||||
@@ -54,6 +55,7 @@ function App() {
|
|||||||
<Route path="users" element={<UserManagementPage />} />
|
<Route path="users" element={<UserManagementPage />} />
|
||||||
<Route path="settings" element={<SettingsPage />} />
|
<Route path="settings" element={<SettingsPage />} />
|
||||||
<Route path="clients" element={<ClientsPage />} />
|
<Route path="clients" element={<ClientsPage />} />
|
||||||
|
<Route path="calendar" element={<CalendarPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -0,0 +1,342 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
|
import { useCalendar } from './useCalendar';
|
||||||
|
import { useIsMobile } from '../../hooks/useIsMobile';
|
||||||
|
|
||||||
|
const MONTH_NAMES = [
|
||||||
|
'Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь',
|
||||||
|
'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь',
|
||||||
|
];
|
||||||
|
|
||||||
|
const DAY_HEADERS = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
|
||||||
|
|
||||||
|
const STATUS_COLORS = {
|
||||||
|
'новая': '#8C9097',
|
||||||
|
'замер': '#F59E0B',
|
||||||
|
'согласование': '#8b5cf6',
|
||||||
|
'монтаж': '#4361EE',
|
||||||
|
'закрыт': '#22C55E',
|
||||||
|
};
|
||||||
|
|
||||||
|
function getStatusColor(status) {
|
||||||
|
return STATUS_COLORS[status] || '#8C9097';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns a 6×7 array of Date objects for the calendar grid (Mon-first)
|
||||||
|
function buildCalendarDays(year, month) {
|
||||||
|
const firstDay = new Date(year, month, 1);
|
||||||
|
// getDay(): 0=Sun,1=Mon,...,6=Sat — convert to Mon=0,...,Sun=6
|
||||||
|
const startOffset = (firstDay.getDay() + 6) % 7;
|
||||||
|
const days = [];
|
||||||
|
for (let i = 0; i < 42; i++) {
|
||||||
|
const d = new Date(year, month, 1 - startOffset + i);
|
||||||
|
days.push(d);
|
||||||
|
}
|
||||||
|
return days;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSameDay(a, b) {
|
||||||
|
return a.getFullYear() === b.getFullYear() &&
|
||||||
|
a.getMonth() === b.getMonth() &&
|
||||||
|
a.getDate() === b.getDate();
|
||||||
|
}
|
||||||
|
|
||||||
|
function OrderChip({ order }) {
|
||||||
|
const color = getStatusColor(order.status);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
title={`${order.name} — ${order.status}`}
|
||||||
|
style={{
|
||||||
|
background: color + '1A',
|
||||||
|
color,
|
||||||
|
fontSize: '0.68rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
padding: '2px 6px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
maxWidth: '100%',
|
||||||
|
lineHeight: 1.4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{order.name}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DayCell({ date, orders, isCurrentMonth, isToday, isSelected, onSelect, isMobile }) {
|
||||||
|
const dayOrders = orders.filter((o) => o.scheduled_at && isSameDay(new Date(o.scheduled_at), date));
|
||||||
|
const visible = dayOrders.slice(0, 2);
|
||||||
|
const extra = dayOrders.length - visible.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={() => onSelect(date)}
|
||||||
|
style={{
|
||||||
|
minHeight: isMobile ? '60px' : '80px',
|
||||||
|
padding: isMobile ? '4px' : '6px 8px',
|
||||||
|
border: isSelected ? '2px solid #4361EE' : '1px solid #E9ECEF',
|
||||||
|
borderRadius: '8px',
|
||||||
|
background: isToday ? '#EEF2FF' : '#FFFFFF',
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '3px',
|
||||||
|
transition: 'border-color 0.15s, background 0.15s',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!isSelected) e.currentTarget.style.borderColor = '#A5B4FC';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (!isSelected) e.currentTarget.style.borderColor = '#E9ECEF';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: isMobile ? '0.72rem' : '0.8rem',
|
||||||
|
fontWeight: isToday ? 700 : 500,
|
||||||
|
color: isToday
|
||||||
|
? '#4361EE'
|
||||||
|
: isCurrentMonth
|
||||||
|
? 'var(--text-main, #1E1E2D)'
|
||||||
|
: 'var(--text-muted, #8C9097)',
|
||||||
|
lineHeight: 1,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{date.getDate()}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{!isMobile && visible.map((o) => (
|
||||||
|
<OrderChip key={o.id} order={o} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{isMobile && dayOrders.length > 0 && (
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '3px' }}>
|
||||||
|
{dayOrders.map((o) => (
|
||||||
|
<div
|
||||||
|
key={o.id}
|
||||||
|
style={{
|
||||||
|
width: '8px', height: '8px', borderRadius: '50%',
|
||||||
|
background: getStatusColor(o.status),
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isMobile && extra > 0 && (
|
||||||
|
<span style={{ fontSize: '0.65rem', color: '#8C9097', fontWeight: 600 }}>
|
||||||
|
+{extra} ещё
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DayDetail({ date, orders }) {
|
||||||
|
const dayOrders = orders.filter((o) => o.scheduled_at && isSameDay(new Date(o.scheduled_at), date));
|
||||||
|
|
||||||
|
if (dayOrders.length === 0) {
|
||||||
|
return (
|
||||||
|
<div style={{ color: 'var(--text-muted, #8C9097)', fontSize: '0.85rem' }}>
|
||||||
|
На этот день записей нет
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||||
|
{dayOrders.map((o) => {
|
||||||
|
const color = getStatusColor(o.status);
|
||||||
|
const time = new Date(o.scheduled_at).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={o.id}
|
||||||
|
className="glass-panel"
|
||||||
|
style={{
|
||||||
|
padding: '12px 16px',
|
||||||
|
borderLeft: `3px solid ${color}`,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: '12px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontFamily: 'var(--font-heading)', fontWeight: 700, fontSize: '0.92rem', marginBottom: '2px' }}>
|
||||||
|
{o.name}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.78rem', color: 'var(--text-muted, #8C9097)' }}>
|
||||||
|
{o.phone}{o.area_sqm ? ` · ${o.area_sqm} м²` : ''}{o.film_thickness ? ` · ${o.film_thickness} мкм` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: '4px', flexShrink: 0 }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
background: color + '1A',
|
||||||
|
color,
|
||||||
|
fontSize: '0.72rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
padding: '2px 8px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.3px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{o.status}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: '0.75rem', color: 'var(--text-muted, #8C9097)' }}>{time}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const CalendarPage = () => {
|
||||||
|
const today = new Date();
|
||||||
|
const [year, setYear] = useState(today.getFullYear());
|
||||||
|
const [month, setMonth] = useState(today.getMonth());
|
||||||
|
const [selectedDate, setSelectedDate] = useState(today);
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
const { orders, loading } = useCalendar(year, month);
|
||||||
|
|
||||||
|
const prevMonth = () => {
|
||||||
|
if (month === 0) { setYear((y) => y - 1); setMonth(11); }
|
||||||
|
else setMonth((m) => m - 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextMonth = () => {
|
||||||
|
if (month === 11) { setYear((y) => y + 1); setMonth(0); }
|
||||||
|
else setMonth((m) => m + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const calendarDays = buildCalendarDays(year, month);
|
||||||
|
|
||||||
|
const selectedDateLabel = selectedDate.toLocaleDateString('ru-RU', {
|
||||||
|
weekday: 'long', day: 'numeric', month: 'long',
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: isMobile ? '16px' : '30px', maxWidth: '1100px' }}>
|
||||||
|
<h1 style={{
|
||||||
|
fontFamily: 'var(--font-heading)', fontSize: '1.5rem', fontWeight: 800,
|
||||||
|
marginBottom: '24px',
|
||||||
|
}}>
|
||||||
|
Календарь
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{/* Month navigation header */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||||
|
marginBottom: '16px',
|
||||||
|
}}>
|
||||||
|
<button
|
||||||
|
onClick={prevMonth}
|
||||||
|
aria-label="Предыдущий месяц"
|
||||||
|
style={{
|
||||||
|
background: 'none', border: '1.5px solid #E9ECEF',
|
||||||
|
borderRadius: '8px', cursor: 'pointer', padding: '6px 10px',
|
||||||
|
display: 'flex', alignItems: 'center', color: '#1E1E2D',
|
||||||
|
transition: 'border-color 0.15s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => e.currentTarget.style.borderColor = '#4361EE'}
|
||||||
|
onMouseLeave={(e) => e.currentTarget.style.borderColor = '#E9ECEF'}
|
||||||
|
>
|
||||||
|
<ChevronLeft size={18} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<h2 style={{
|
||||||
|
fontFamily: 'var(--font-heading)', fontWeight: 700,
|
||||||
|
fontSize: isMobile ? '1rem' : '1.15rem',
|
||||||
|
color: '#1E1E2D',
|
||||||
|
}}>
|
||||||
|
{MONTH_NAMES[month]} {year}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={nextMonth}
|
||||||
|
aria-label="Следующий месяц"
|
||||||
|
style={{
|
||||||
|
background: 'none', border: '1.5px solid #E9ECEF',
|
||||||
|
borderRadius: '8px', cursor: 'pointer', padding: '6px 10px',
|
||||||
|
display: 'flex', alignItems: 'center', color: '#1E1E2D',
|
||||||
|
transition: 'border-color 0.15s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => e.currentTarget.style.borderColor = '#4361EE'}
|
||||||
|
onMouseLeave={(e) => e.currentTarget.style.borderColor = '#E9ECEF'}
|
||||||
|
>
|
||||||
|
<ChevronRight size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Day headers */}
|
||||||
|
<div style={{
|
||||||
|
display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)',
|
||||||
|
gap: '4px', marginBottom: '4px',
|
||||||
|
}}>
|
||||||
|
{DAY_HEADERS.map((d) => (
|
||||||
|
<div
|
||||||
|
key={d}
|
||||||
|
style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
fontSize: '0.72rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: '#8C9097',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.5px',
|
||||||
|
padding: '4px 0',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{d}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Calendar grid */}
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ padding: '40px', textAlign: 'center', color: '#8C9097', fontSize: '0.9rem' }}>
|
||||||
|
Загрузка...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{
|
||||||
|
display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)',
|
||||||
|
gap: '4px',
|
||||||
|
}}>
|
||||||
|
{calendarDays.map((date, idx) => (
|
||||||
|
<DayCell
|
||||||
|
key={idx}
|
||||||
|
date={date}
|
||||||
|
orders={orders}
|
||||||
|
isCurrentMonth={date.getMonth() === month}
|
||||||
|
isToday={isSameDay(date, today)}
|
||||||
|
isSelected={isSameDay(date, selectedDate)}
|
||||||
|
onSelect={setSelectedDate}
|
||||||
|
isMobile={isMobile}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Day detail panel */}
|
||||||
|
<div style={{ marginTop: '28px' }}>
|
||||||
|
<h3 style={{
|
||||||
|
fontFamily: 'var(--font-heading)', fontWeight: 700,
|
||||||
|
fontSize: '0.95rem', marginBottom: '14px',
|
||||||
|
color: '#1E1E2D',
|
||||||
|
textTransform: 'capitalize',
|
||||||
|
}}>
|
||||||
|
{selectedDateLabel}
|
||||||
|
</h3>
|
||||||
|
{loading ? null : <DayDetail date={selectedDate} orders={orders} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CalendarPage;
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { supabase } from '../../lib/supabase';
|
||||||
|
|
||||||
|
export function useCalendar(year, month) {
|
||||||
|
const [orders, setOrders] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const from = new Date(year, month, 1).toISOString();
|
||||||
|
const to = new Date(year, month + 1, 0, 23, 59, 59).toISOString();
|
||||||
|
|
||||||
|
supabase
|
||||||
|
.from('orders')
|
||||||
|
.select('id, name, phone, status, scheduled_at, film_thickness, area_sqm')
|
||||||
|
.gte('scheduled_at', from)
|
||||||
|
.lte('scheduled_at', to)
|
||||||
|
.order('scheduled_at', { ascending: true })
|
||||||
|
.then(({ data }) => {
|
||||||
|
setOrders(data || []);
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
}, [year, month]);
|
||||||
|
|
||||||
|
return { orders, loading };
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
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 } from 'lucide-react';
|
import { Plus, Menu, Bell, BellOff } 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';
|
||||||
|
|
||||||
const PAGE_TITLES = {
|
const PAGE_TITLES = {
|
||||||
'/admin/kanban': 'Канбан',
|
'/admin/kanban': 'Канбан',
|
||||||
@@ -18,6 +19,7 @@ const AdminLayout = () => {
|
|||||||
const [newCount, setNewCount] = useState(0);
|
const [newCount, setNewCount] = useState(0);
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
const { supported: pushSupported, subscribed: pushSubscribed, loading: pushLoading, subscribe: pushSubscribe, unsubscribe: pushUnsubscribe } = usePushNotifications();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const refreshBadge = () => {
|
const refreshBadge = () => {
|
||||||
@@ -98,14 +100,38 @@ const AdminLayout = () => {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
onClick={() => setShowNewOrder(true)}
|
{pushSupported && (
|
||||||
className="btn-primary"
|
<button
|
||||||
style={{ padding: '8px 18px', fontSize: '0.88rem', display: 'flex', alignItems: 'center', gap: '7px' }}
|
onClick={pushSubscribed ? pushUnsubscribe : pushSubscribe}
|
||||||
>
|
disabled={pushLoading}
|
||||||
<Plus size={15} />
|
title={pushSubscribed ? 'Отключить уведомления' : 'Включить уведомления'}
|
||||||
Заявка
|
aria-label={pushSubscribed ? 'Отключить push-уведомления' : 'Включить push-уведомления'}
|
||||||
</button>
|
style={{
|
||||||
|
background: pushSubscribed ? 'rgba(67,97,238,0.08)' : 'none',
|
||||||
|
border: '1.5px solid var(--border-light)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '7px',
|
||||||
|
cursor: pushLoading ? 'not-allowed' : 'pointer',
|
||||||
|
color: pushSubscribed ? '#4361EE' : 'var(--text-muted)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
opacity: pushLoading ? 0.6 : 1,
|
||||||
|
transition: 'opacity 0.15s, background 0.15s, color 0.15s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{pushSubscribed ? <Bell size={16} /> : <BellOff size={16} />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowNewOrder(true)}
|
||||||
|
className="btn-primary"
|
||||||
|
style={{ padding: '8px 18px', fontSize: '0.88rem', display: 'flex', alignItems: 'center', gap: '7px' }}
|
||||||
|
>
|
||||||
|
<Plus size={15} />
|
||||||
|
Заявка
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Page content */}
|
{/* Page content */}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { NavLink, useNavigate } from 'react-router-dom';
|
import { NavLink, useNavigate } from 'react-router-dom';
|
||||||
import { LayoutDashboard, BarChart2, CheckSquare, Shield, LogOut, Users, Settings, BookUser, X } from 'lucide-react';
|
import { LayoutDashboard, BarChart2, CheckSquare, Shield, LogOut, Users, Settings, BookUser, X, CalendarDays } from 'lucide-react';
|
||||||
import { useAuth } from '../auth/useAuth';
|
import { useAuth } from '../auth/useAuth';
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
{ to: '/admin/kanban', icon: LayoutDashboard, label: 'Канбан' },
|
{ to: '/admin/kanban', icon: LayoutDashboard, label: 'Канбан' },
|
||||||
{ to: '/admin/analytics', icon: BarChart2, label: 'Аналитика' },
|
{ to: '/admin/analytics', icon: BarChart2, label: 'Аналитика' },
|
||||||
{ to: '/admin/completed', icon: CheckSquare, label: 'Завершённые' },
|
{ to: '/admin/completed', icon: CheckSquare, label: 'Завершённые' },
|
||||||
|
{ to: '/admin/calendar', icon: CalendarDays, label: 'Календарь' },
|
||||||
{ to: '/admin/users', icon: Users, label: 'Пользователи' },
|
{ to: '/admin/users', icon: Users, label: 'Пользователи' },
|
||||||
{ to: '/admin/clients', icon: BookUser, label: 'Клиенты' },
|
{ to: '/admin/clients', icon: BookUser, label: 'Клиенты' },
|
||||||
{ to: '/admin/settings', icon: Settings, label: 'Настройки' },
|
{ to: '/admin/settings', icon: Settings, label: 'Настройки' },
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ const OrderModal = ({ order, onClose, onSaved, mode = 'edit' }) => {
|
|||||||
telegram_username: order?.telegram_username || '',
|
telegram_username: order?.telegram_username || '',
|
||||||
max_username: order?.max_username || '',
|
max_username: order?.max_username || '',
|
||||||
address: order?.address || '',
|
address: order?.address || '',
|
||||||
|
scheduled_at: order?.scheduled_at || '',
|
||||||
});
|
});
|
||||||
const [events, setEvents] = useState([]);
|
const [events, setEvents] = useState([]);
|
||||||
const { photos, uploading, uploadPhotos, deletePhoto } = useOrderPhotos(mode === 'edit' ? order?.id : null);
|
const { photos, uploading, uploadPhotos, deletePhoto } = useOrderPhotos(mode === 'edit' ? order?.id : null);
|
||||||
@@ -92,6 +93,7 @@ const OrderModal = ({ order, onClose, onSaved, mode = 'edit' }) => {
|
|||||||
...f,
|
...f,
|
||||||
area_sqm: f.area_sqm === '' ? null : Number(f.area_sqm),
|
area_sqm: f.area_sqm === '' ? null : Number(f.area_sqm),
|
||||||
final_cost: f.final_cost === '' ? null : Number(f.final_cost),
|
final_cost: f.final_cost === '' ? null : Number(f.final_cost),
|
||||||
|
scheduled_at: f.scheduled_at === '' ? null : f.scheduled_at,
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
@@ -240,6 +242,17 @@ const OrderModal = ({ order, onClose, onSaved, mode = 'edit' }) => {
|
|||||||
{STATUSES.map((s) => <option key={s} value={s}>{s}</option>)}
|
{STATUSES.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ gridColumn: '1 / -1' }}>
|
||||||
|
<label htmlFor="modal-scheduled" style={labelStyle}>Дата визита</label>
|
||||||
|
<input
|
||||||
|
id="modal-scheduled"
|
||||||
|
type="datetime-local"
|
||||||
|
name="scheduled_at"
|
||||||
|
value={form.scheduled_at ? new Date(form.scheduled_at).toISOString().slice(0, 16) : ''}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, scheduled_at: e.target.value ? new Date(e.target.value).toISOString() : '' }))}
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="modal-area" style={labelStyle}>Площадь (м²)</label>
|
<label htmlFor="modal-area" style={labelStyle}>Площадь (м²)</label>
|
||||||
<input id="modal-area" type="number" name="area_sqm" value={form.area_sqm} onChange={set} style={inputStyle} />
|
<input id="modal-area" type="number" name="area_sqm" value={form.area_sqm} onChange={set} style={inputStyle} />
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { supabase } from '../lib/supabase';
|
||||||
|
|
||||||
|
const VAPID_PUBLIC_KEY = import.meta.env.VITE_VAPID_PUBLIC_KEY;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a base64url string to a Uint8Array suitable for
|
||||||
|
* PushManager.subscribe({ applicationServerKey }).
|
||||||
|
*/
|
||||||
|
function urlB64ToUint8Array(base64String) {
|
||||||
|
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
|
||||||
|
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
const rawData = atob(base64);
|
||||||
|
return Uint8Array.from([...rawData].map((c) => c.charCodeAt(0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook that manages Web Push subscription state for the current admin user.
|
||||||
|
*
|
||||||
|
* Returns:
|
||||||
|
* supported – browser supports Push API
|
||||||
|
* subscribed – a push subscription is active
|
||||||
|
* loading – async operation in progress
|
||||||
|
* subscribe – enable push & save subscription to Supabase
|
||||||
|
* unsubscribe – revoke push & remove from Supabase
|
||||||
|
*/
|
||||||
|
export function usePushNotifications() {
|
||||||
|
const [supported, setSupported] = useState(false);
|
||||||
|
const [subscribed, setSubscribed] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// Detect browser support on mount
|
||||||
|
useEffect(() => {
|
||||||
|
setSupported('serviceWorker' in navigator && 'PushManager' in window);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Sync subscribed state from current SW registration
|
||||||
|
useEffect(() => {
|
||||||
|
if (!supported) return;
|
||||||
|
navigator.serviceWorker.ready.then((reg) => {
|
||||||
|
reg.pushManager.getSubscription().then((sub) => setSubscribed(!!sub));
|
||||||
|
});
|
||||||
|
}, [supported]);
|
||||||
|
|
||||||
|
const subscribe = async () => {
|
||||||
|
if (!supported || !VAPID_PUBLIC_KEY) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const reg = await navigator.serviceWorker.ready;
|
||||||
|
const sub = await reg.pushManager.subscribe({
|
||||||
|
userVisibleOnly: true,
|
||||||
|
applicationServerKey: urlB64ToUint8Array(VAPID_PUBLIC_KEY),
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
await supabase.from('push_subscriptions').upsert(
|
||||||
|
{ user_id: user.id, subscription: sub.toJSON() },
|
||||||
|
{ onConflict: 'user_id' },
|
||||||
|
);
|
||||||
|
|
||||||
|
setSubscribed(true);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Push subscribe failed:', e);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const unsubscribe = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const reg = await navigator.serviceWorker.ready;
|
||||||
|
const sub = await reg.pushManager.getSubscription();
|
||||||
|
if (sub) await sub.unsubscribe();
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
await supabase.from('push_subscriptions').delete().eq('user_id', user.id);
|
||||||
|
|
||||||
|
setSubscribed(false);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Push unsubscribe failed:', e);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { supported, subscribed, loading, subscribe, unsubscribe };
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { precacheAndRoute } from 'workbox-precaching';
|
||||||
|
|
||||||
|
// Workbox precache manifest — injected at build time by vite-plugin-pwa
|
||||||
|
precacheAndRoute(self.__WB_MANIFEST);
|
||||||
|
|
||||||
|
// ── Push notification handler ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
self.addEventListener('push', (event) => {
|
||||||
|
const data = event.data?.json() ?? {};
|
||||||
|
event.waitUntil(
|
||||||
|
self.registration.showNotification(data.title || 'Осколкам.Нет', {
|
||||||
|
body: data.body || '',
|
||||||
|
icon: '/pwa-192x192.png',
|
||||||
|
badge: '/pwa-64x64.png',
|
||||||
|
data: { url: data.url || '/admin/kanban' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('notificationclick', (event) => {
|
||||||
|
event.notification.close();
|
||||||
|
event.waitUntil(
|
||||||
|
clients.openWindow(event.notification.data?.url || '/admin/kanban'),
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
|
||||||
|
|
||||||
|
const corsHeaders = {
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Minimal Web Push implementation using Deno crypto
|
||||||
|
async function sendWebPush(
|
||||||
|
subscription: { endpoint: string; keys: { p256dh: string; auth: string } },
|
||||||
|
payload: string,
|
||||||
|
vapidPrivateKeyB64: string,
|
||||||
|
vapidPublicKeyB64: string,
|
||||||
|
): Promise<number> {
|
||||||
|
const endpoint = subscription.endpoint;
|
||||||
|
const audience = new URL(endpoint).origin;
|
||||||
|
|
||||||
|
const header = { alg: 'ES256', typ: 'JWT' };
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const claims = {
|
||||||
|
aud: audience,
|
||||||
|
exp: now + 12 * 3600,
|
||||||
|
sub: 'mailto:admin@oskolkam.net',
|
||||||
|
};
|
||||||
|
|
||||||
|
const encode = (obj: object) =>
|
||||||
|
btoa(JSON.stringify(obj))
|
||||||
|
.replace(/\+/g, '-')
|
||||||
|
.replace(/\//g, '_')
|
||||||
|
.replace(/=+$/, '');
|
||||||
|
|
||||||
|
const headerB64 = encode(header);
|
||||||
|
const claimsB64 = encode(claims);
|
||||||
|
const signingInput = `${headerB64}.${claimsB64}`;
|
||||||
|
|
||||||
|
// Import private key from raw 32-byte scalar
|
||||||
|
const privKeyBytes = Uint8Array.from(
|
||||||
|
atob(vapidPrivateKeyB64.replace(/-/g, '+').replace(/_/g, '/')),
|
||||||
|
(c) => c.charCodeAt(0),
|
||||||
|
);
|
||||||
|
const cryptoKey = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
privKeyBytes,
|
||||||
|
{ name: 'ECDSA', namedCurve: 'P-256' },
|
||||||
|
false,
|
||||||
|
['sign'],
|
||||||
|
);
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const sig = await crypto.subtle.sign(
|
||||||
|
{ name: 'ECDSA', hash: 'SHA-256' },
|
||||||
|
cryptoKey,
|
||||||
|
encoder.encode(signingInput),
|
||||||
|
);
|
||||||
|
const sigB64 = btoa(String.fromCharCode(...new Uint8Array(sig)))
|
||||||
|
.replace(/\+/g, '-')
|
||||||
|
.replace(/\//g, '_')
|
||||||
|
.replace(/=+$/, '');
|
||||||
|
const jwt = `${signingInput}.${sigB64}`;
|
||||||
|
|
||||||
|
const res = await fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `vapid t=${jwt},k=${vapidPublicKeyB64}`,
|
||||||
|
'Content-Type': 'application/octet-stream',
|
||||||
|
'TTL': '86400',
|
||||||
|
},
|
||||||
|
body: encoder.encode(payload),
|
||||||
|
});
|
||||||
|
return res.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
Deno.serve(async (req) => {
|
||||||
|
if (req.method === 'OPTIONS') {
|
||||||
|
return new Response('ok', { headers: corsHeaders });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.json().catch(() => ({}));
|
||||||
|
const order = body.record;
|
||||||
|
|
||||||
|
const supabaseAdmin = createClient(
|
||||||
|
Deno.env.get('SUPABASE_URL')!,
|
||||||
|
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: subs } = await supabaseAdmin
|
||||||
|
.from('push_subscriptions')
|
||||||
|
.select('subscription');
|
||||||
|
|
||||||
|
if (!subs?.length) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ sent: 0 }),
|
||||||
|
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
title: 'Новая заявка',
|
||||||
|
body: `${order?.name || 'Клиент'} — ${order?.phone || ''}`,
|
||||||
|
url: '/admin/kanban',
|
||||||
|
});
|
||||||
|
|
||||||
|
const vapidPriv = Deno.env.get('VAPID_PRIVATE_KEY')!;
|
||||||
|
const vapidPub = Deno.env.get('VITE_VAPID_PUBLIC_KEY')!;
|
||||||
|
|
||||||
|
let sent = 0;
|
||||||
|
for (const row of subs) {
|
||||||
|
try {
|
||||||
|
const status = await sendWebPush(row.subscription, payload, vapidPriv, vapidPub);
|
||||||
|
if (status === 200 || status === 201) sent++;
|
||||||
|
// Clean up expired/invalid subscriptions
|
||||||
|
if (status === 404 || status === 410) {
|
||||||
|
await supabaseAdmin
|
||||||
|
.from('push_subscriptions')
|
||||||
|
.delete()
|
||||||
|
.eq('subscription->>endpoint', row.subscription.endpoint);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Ignore individual push failures — don't abort the whole batch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ sent }),
|
||||||
|
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' } },
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
-- Push notification subscriptions
|
||||||
|
-- One row per user. The subscription JSONB holds the PushSubscription JSON
|
||||||
|
-- (endpoint, keys.p256dh, keys.auth) returned by the browser PushManager.
|
||||||
|
CREATE TABLE push_subscriptions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
subscription JSONB NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
UNIQUE (user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE push_subscriptions ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- Admins can only manage their own subscription
|
||||||
|
CREATE POLICY "Users manage own subscriptions"
|
||||||
|
ON push_subscriptions
|
||||||
|
USING (auth.uid() = user_id)
|
||||||
|
WITH CHECK (auth.uid() = user_id);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- Add scheduled appointment date to orders
|
||||||
|
ALTER TABLE orders ADD COLUMN IF NOT EXISTS scheduled_at TIMESTAMPTZ;
|
||||||
+6
-8
@@ -6,6 +6,11 @@ export default defineConfig({
|
|||||||
plugins: [
|
plugins: [
|
||||||
react(),
|
react(),
|
||||||
VitePWA({
|
VitePWA({
|
||||||
|
// injectManifest lets us supply our own src/sw.js so we can add
|
||||||
|
// push/notificationclick handlers alongside Workbox precaching.
|
||||||
|
strategies: 'injectManifest',
|
||||||
|
srcDir: 'src',
|
||||||
|
filename: 'sw.js',
|
||||||
registerType: 'autoUpdate',
|
registerType: 'autoUpdate',
|
||||||
includeAssets: ['favicon.svg', 'favicon.ico', 'apple-touch-icon-180x180.png'],
|
includeAssets: ['favicon.svg', 'favicon.ico', 'apple-touch-icon-180x180.png'],
|
||||||
manifest: {
|
manifest: {
|
||||||
@@ -25,15 +30,8 @@ export default defineConfig({
|
|||||||
{ src: 'maskable-icon-512x512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
|
{ src: 'maskable-icon-512x512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
workbox: {
|
injectManifest: {
|
||||||
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
|
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
|
||||||
runtimeCaching: [
|
|
||||||
{
|
|
||||||
urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i,
|
|
||||||
handler: 'CacheFirst',
|
|
||||||
options: { cacheName: 'google-fonts-cache', expiration: { maxEntries: 10, maxAgeSeconds: 60 * 60 * 24 * 365 } },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user