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 } },
- },
- ],
},
}),
],