From af9e7ce1af942b8e9793bf681310cbf07a383490 Mon Sep 17 00:00:00 2001 From: houseassassin Date: Sun, 10 May 2026 17:25:04 +0300 Subject: [PATCH] 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 --- src/App.jsx | 2 + src/admin/calendar/CalendarPage.jsx | 342 ++++++++++++++++++ src/admin/calendar/useCalendar.js | 25 ++ src/admin/layout/AdminLayout.jsx | 44 ++- src/admin/layout/Sidebar.jsx | 3 +- src/admin/orders/OrderModal.jsx | 13 + src/hooks/usePushNotifications.js | 91 +++++ src/sw.js | 25 ++ supabase/functions/push-notify/index.ts | 127 +++++++ .../migrations/002_push_subscriptions.sql | 18 + .../migrations/004_orders_scheduled_at.sql | 2 + vite.config.js | 14 +- 12 files changed, 688 insertions(+), 18 deletions(-) create mode 100644 src/admin/calendar/CalendarPage.jsx create mode 100644 src/admin/calendar/useCalendar.js create mode 100644 src/hooks/usePushNotifications.js create mode 100644 src/sw.js create mode 100644 supabase/functions/push-notify/index.ts create mode 100644 supabase/migrations/002_push_subscriptions.sql create mode 100644 supabase/migrations/004_orders_scheduled_at.sql diff --git a/src/App.jsx b/src/App.jsx index 282c07a..2433214 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -19,6 +19,7 @@ const CompletedOrdersTable = React.lazy(() => import('./admin/analytics/Complete const UserManagementPage = React.lazy(() => import('./admin/users/UserManagementPage')); const SettingsPage = React.lazy(() => import('./admin/settings/SettingsPage')); const ClientsPage = React.lazy(() => import('./admin/clients/ClientsPage')); +const CalendarPage = React.lazy(() => import('./admin/calendar/CalendarPage')); function PublicSite() { return ( @@ -54,6 +55,7 @@ function App() { } /> } /> } /> + } /> diff --git a/src/admin/calendar/CalendarPage.jsx b/src/admin/calendar/CalendarPage.jsx new file mode 100644 index 0000000..dd94cc0 --- /dev/null +++ b/src/admin/calendar/CalendarPage.jsx @@ -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 ( +
+ {order.name} +
+ ); +} + +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 ( +
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'; + }} + > + + {date.getDate()} + + + {!isMobile && visible.map((o) => ( + + ))} + + {isMobile && dayOrders.length > 0 && ( +
+ {dayOrders.map((o) => ( +
+ ))} +
+ )} + + {!isMobile && extra > 0 && ( + + +{extra} ещё + + )} +
+ ); +} + +function DayDetail({ date, orders }) { + const dayOrders = orders.filter((o) => o.scheduled_at && isSameDay(new Date(o.scheduled_at), date)); + + if (dayOrders.length === 0) { + return ( +
+ На этот день записей нет +
+ ); + } + + return ( +
+ {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 ( +
+
+
+ {o.name} +
+
+ {o.phone}{o.area_sqm ? ` · ${o.area_sqm} м²` : ''}{o.film_thickness ? ` · ${o.film_thickness} мкм` : ''} +
+
+
+ + {o.status} + + {time} +
+
+ ); + })} +
+ ); +} + +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 ( +
+

+ Календарь +

+ + {/* Month navigation header */} +
+ + +

+ {MONTH_NAMES[month]} {year} +

+ + +
+ + {/* Day headers */} +
+ {DAY_HEADERS.map((d) => ( +
+ {d} +
+ ))} +
+ + {/* Calendar grid */} + {loading ? ( +
+ Загрузка... +
+ ) : ( +
+ {calendarDays.map((date, idx) => ( + + ))} +
+ )} + + {/* Day detail panel */} +
+

+ {selectedDateLabel} +

+ {loading ? null : } +
+
+ ); +}; + +export default CalendarPage; diff --git a/src/admin/calendar/useCalendar.js b/src/admin/calendar/useCalendar.js new file mode 100644 index 0000000..dac9f48 --- /dev/null +++ b/src/admin/calendar/useCalendar.js @@ -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 }; +} diff --git a/src/admin/layout/AdminLayout.jsx b/src/admin/layout/AdminLayout.jsx index 5f02ac5..0f85609 100644 --- a/src/admin/layout/AdminLayout.jsx +++ b/src/admin/layout/AdminLayout.jsx @@ -1,9 +1,10 @@ import { useState, useEffect } from 'react'; 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 { supabase } from '../../lib/supabase'; import { useIsMobile } from '../../hooks/useIsMobile'; +import { usePushNotifications } from '../../hooks/usePushNotifications'; const PAGE_TITLES = { '/admin/kanban': 'Канбан', @@ -18,6 +19,7 @@ const AdminLayout = () => { const [newCount, setNewCount] = useState(0); const [sidebarOpen, setSidebarOpen] = useState(false); const isMobile = useIsMobile(); + const { supported: pushSupported, subscribed: pushSubscribed, loading: pushLoading, subscribe: pushSubscribe, unsubscribe: pushUnsubscribe } = usePushNotifications(); useEffect(() => { const refreshBadge = () => { @@ -98,14 +100,38 @@ const AdminLayout = () => { )}
- +
+ {pushSupported && ( + + )} + +
{/* Page content */} diff --git a/src/admin/layout/Sidebar.jsx b/src/admin/layout/Sidebar.jsx index 2785715..3566ba8 100644 --- a/src/admin/layout/Sidebar.jsx +++ b/src/admin/layout/Sidebar.jsx @@ -1,11 +1,12 @@ 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'; const NAV_ITEMS = [ { to: '/admin/kanban', icon: LayoutDashboard, label: 'Канбан' }, { to: '/admin/analytics', icon: BarChart2, label: 'Аналитика' }, { to: '/admin/completed', icon: CheckSquare, label: 'Завершённые' }, + { to: '/admin/calendar', icon: CalendarDays, label: 'Календарь' }, { to: '/admin/users', icon: Users, label: 'Пользователи' }, { to: '/admin/clients', icon: BookUser, label: 'Клиенты' }, { to: '/admin/settings', icon: Settings, label: 'Настройки' }, diff --git a/src/admin/orders/OrderModal.jsx b/src/admin/orders/OrderModal.jsx index d42e581..2669909 100644 --- a/src/admin/orders/OrderModal.jsx +++ b/src/admin/orders/OrderModal.jsx @@ -50,6 +50,7 @@ const OrderModal = ({ order, onClose, onSaved, mode = 'edit' }) => { telegram_username: order?.telegram_username || '', max_username: order?.max_username || '', address: order?.address || '', + scheduled_at: order?.scheduled_at || '', }); const [events, setEvents] = useState([]); const { photos, uploading, uploadPhotos, deletePhoto } = useOrderPhotos(mode === 'edit' ? order?.id : null); @@ -92,6 +93,7 @@ const OrderModal = ({ order, onClose, onSaved, mode = 'edit' }) => { ...f, area_sqm: f.area_sqm === '' ? null : Number(f.area_sqm), final_cost: f.final_cost === '' ? null : Number(f.final_cost), + scheduled_at: f.scheduled_at === '' ? null : f.scheduled_at, }); const handleSave = async () => { @@ -240,6 +242,17 @@ const OrderModal = ({ order, onClose, onSaved, mode = 'edit' }) => { {STATUSES.map((s) => )} +
+ + setForm((prev) => ({ ...prev, scheduled_at: e.target.value ? new Date(e.target.value).toISOString() : '' }))} + style={inputStyle} + /> +
diff --git a/src/hooks/usePushNotifications.js b/src/hooks/usePushNotifications.js new file mode 100644 index 0000000..d683e8d --- /dev/null +++ b/src/hooks/usePushNotifications.js @@ -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 }; +} diff --git a/src/sw.js b/src/sw.js new file mode 100644 index 0000000..57b5fcf --- /dev/null +++ b/src/sw.js @@ -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'), + ); +}); diff --git a/supabase/functions/push-notify/index.ts b/supabase/functions/push-notify/index.ts new file mode 100644 index 0000000..38e7b2d --- /dev/null +++ b/supabase/functions/push-notify/index.ts @@ -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 { + 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' } }, + ); +}); diff --git a/supabase/migrations/002_push_subscriptions.sql b/supabase/migrations/002_push_subscriptions.sql new file mode 100644 index 0000000..012de36 --- /dev/null +++ b/supabase/migrations/002_push_subscriptions.sql @@ -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); diff --git a/supabase/migrations/004_orders_scheduled_at.sql b/supabase/migrations/004_orders_scheduled_at.sql new file mode 100644 index 0000000..af230bc --- /dev/null +++ b/supabase/migrations/004_orders_scheduled_at.sql @@ -0,0 +1,2 @@ +-- Add scheduled appointment date to orders +ALTER TABLE orders ADD COLUMN IF NOT EXISTS scheduled_at TIMESTAMPTZ; diff --git a/vite.config.js b/vite.config.js index 09f9c88..eacea4d 100644 --- a/vite.config.js +++ b/vite.config.js @@ -6,6 +6,11 @@ export default defineConfig({ plugins: [ react(), 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', includeAssets: ['favicon.svg', 'favicon.ico', 'apple-touch-icon-180x180.png'], manifest: { @@ -25,15 +30,8 @@ export default defineConfig({ { src: 'maskable-icon-512x512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' }, ], }, - workbox: { + injectManifest: { 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 } }, - }, - ], }, }), ],