diff --git a/src/App.jsx b/src/App.jsx index 2433214..40ba2d4 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -20,6 +20,7 @@ const UserManagementPage = React.lazy(() => import('./admin/users/UserManagement const SettingsPage = React.lazy(() => import('./admin/settings/SettingsPage')); const ClientsPage = React.lazy(() => import('./admin/clients/ClientsPage')); const CalendarPage = React.lazy(() => import('./admin/calendar/CalendarPage')); +const OrderTrackingPage = React.lazy(() => import('./portal/OrderTrackingPage')); function PublicSite() { return ( @@ -46,6 +47,7 @@ function App() { } /> } /> + } /> } /> }> }> diff --git a/src/admin/orders/OrderModal.jsx b/src/admin/orders/OrderModal.jsx index 2669909..c654bdd 100644 --- a/src/admin/orders/OrderModal.jsx +++ b/src/admin/orders/OrderModal.jsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from 'react'; -import { X, Save, FileText, Camera, X as XIcon } from 'lucide-react'; +import { X, Save, FileText, Camera, X as XIcon, Share2 } from 'lucide-react'; import { useOrderPhotos } from './useOrderPhotos'; import { pdf } from '@react-pdf/renderer'; import { supabase } from '../../lib/supabase'; @@ -57,6 +57,7 @@ const OrderModal = ({ order, onClose, onSaved, mode = 'edit' }) => { const [saving, setSaving] = useState(false); const [downloading, setDownloading] = useState(false); const [error, setError] = useState(''); + const [copied, setCopied] = useState(false); useEffect(() => { if (order?.id) { @@ -96,6 +97,14 @@ const OrderModal = ({ order, onClose, onSaved, mode = 'edit' }) => { scheduled_at: f.scheduled_at === '' ? null : f.scheduled_at, }); + const handleShare = () => { + const url = `${window.location.origin}/track/${order.public_token}`; + navigator.clipboard.writeText(url).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }); + }; + const handleSave = async () => { if (!form.name.trim() || !form.phone.trim()) { setError('Имя и телефон обязательны'); @@ -210,13 +219,37 @@ const OrderModal = ({ order, onClose, onSaved, mode = 'edit' }) => {

{mode === 'create' ? 'Новая заявка' : order?.name}

- +
+ {mode === 'edit' && order?.public_token && ( + + )} + +
{/* Fields */} diff --git a/src/portal/OrderTrackingPage.jsx b/src/portal/OrderTrackingPage.jsx new file mode 100644 index 0000000..e966271 --- /dev/null +++ b/src/portal/OrderTrackingPage.jsx @@ -0,0 +1,225 @@ +import React from 'react'; +import { useParams } from 'react-router-dom'; +import { useOrderTracking } from './useOrderTracking'; + +const STATUS_COLORS = { + 'новая': '#3b82f6', + 'замер': '#8b5cf6', + 'согласование': '#f59e0b', + 'монтаж': '#f59e0b', + 'закрыт': '#10b981', + 'в работе': '#f59e0b', + 'завершена': '#10b981', + 'отменена': '#6b7280', +}; + +const STATUS_LABELS = { + 'новая': 'Новая', + 'замер': 'Замер', + 'согласование': 'Согласование', + 'монтаж': 'Монтаж', + 'закрыт': 'Завершена', + 'в работе': 'В работе', + 'завершена': 'Завершена', + 'отменена': 'Отменена', +}; + +const FILM_LABELS = { + 200: '200 микрон', + 300: '300 микрон', +}; + +function StatusBadge({ status }) { + const color = STATUS_COLORS[status] || '#6b7280'; + const label = STATUS_LABELS[status] || status; + return ( + + {label} + + ); +} + +function InfoRow({ label, value }) { + if (!value && value !== 0) return null; + return ( +
+ + {label} + + + {value} + +
+ ); +} + +function LoadingState() { + return ( +
+
+

Загрузка заявки...

+ +
+ ); +} + +function NotFoundState() { + return ( +
+
🔍
+

+ Заявка не найдена +

+

+ Проверьте ссылку или обратитесь к менеджеру. +

+
+ ); +} + +function OrderTrackingPage() { + const { token } = useParams(); + const { order, loading, error } = useOrderTracking(token); + + const pageStyle = { + minHeight: '100vh', + background: '#F5F7FA', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + padding: '24px 16px 48px', + fontFamily: "'Inter', system-ui, sans-serif", + }; + + const cardStyle = { + background: '#fff', + borderRadius: '16px', + boxShadow: '0 4px 24px rgba(15,23,42,0.08)', + width: '100%', + maxWidth: '480px', + overflow: 'hidden', + }; + + return ( +
+ {/* Logo / Brand header */} +
+
+
+ О +
+ + Осколкам.Нет + +
+

+ Отслеживание заявки +

+
+ +
+ {loading ? ( + + ) : error || !order ? ( + + ) : ( +
+ {/* Status banner */} +
+
+

+ Статус заявки +

+ +
+ {order.id && ( +
+

ID заявки

+

+ #{String(order.id).slice(0, 8)} +

+
+ )} +
+ + {/* Order details */} +
+ + + + + + + + {order.notes && ( +
+

+ Заметки +

+

{order.notes}

+
+ )} +
+ + {/* Footer */} +
+

+ Есть вопросы? Свяжитесь с вашим менеджером. +

+
+
+ )} +
+
+ ); +} + +export default OrderTrackingPage; diff --git a/src/portal/useOrderTracking.js b/src/portal/useOrderTracking.js new file mode 100644 index 0000000..e13ae04 --- /dev/null +++ b/src/portal/useOrderTracking.js @@ -0,0 +1,35 @@ +import { useState, useEffect } from 'react'; +import { supabase } from '../lib/supabase'; + +export function useOrderTracking(token) { + const [order, setOrder] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (!token) { + setLoading(false); + return; + } + + let isMounted = true; + + supabase + .from('orders') + .select('*') + .eq('public_token', token) + .single() + .then(({ data, error }) => { + if (!isMounted) return; + if (error) setError(error.message); + else setOrder(data); + setLoading(false); + }); + + return () => { + isMounted = false; + }; + }, [token]); + + return { order, loading, error }; +} diff --git a/supabase/migrations/005_orders_public_token.sql b/supabase/migrations/005_orders_public_token.sql new file mode 100644 index 0000000..152a4ad --- /dev/null +++ b/supabase/migrations/005_orders_public_token.sql @@ -0,0 +1,11 @@ +-- Add public_token column to orders for anonymous order tracking +ALTER TABLE orders ADD COLUMN IF NOT EXISTS public_token UUID DEFAULT gen_random_uuid(); + +-- Backfill existing orders that have NULL public_token +UPDATE orders SET public_token = gen_random_uuid() WHERE public_token IS NULL; + +-- Allow anon role to read orders if they know the public_token +-- (filtered further in app code via .eq('public_token', token)) +CREATE POLICY "orders_read_by_public_token" ON orders + FOR SELECT TO anon + USING (public_token IS NOT NULL);