diff --git a/.gitignore b/.gitignore index 244c37a..4a919bc 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ volumes/db/data/ __pycache__/ *.pyc *.bak +*.bak-* diff --git a/scripts/__pycache__/sms_first_campaign.cpython-311.pyc b/scripts/__pycache__/sms_first_campaign.cpython-311.pyc deleted file mode 100644 index 3752faf..0000000 Binary files a/scripts/__pycache__/sms_first_campaign.cpython-311.pyc and /dev/null differ diff --git a/src/components/driver/DriverDeliveryPlanner.jsx.bak b/src/components/driver/DriverDeliveryPlanner.jsx.bak deleted file mode 100644 index a5465cb..0000000 --- a/src/components/driver/DriverDeliveryPlanner.jsx.bak +++ /dev/null @@ -1,371 +0,0 @@ -import React from "react"; -import { - getOrderGroupDeliveryHalfDay, - getOrderGroupDeliveryStatusLabel, - getOrderGroupDeliveryStatusTone, - DRIVER_VISIBLE_DELIVERY_STATUSES, - isOrderGroupVisibleToDriver, - groupOrderGroupsByDate, - parseGroupDate, -} from "../../services/orderGroupViews"; -import { Badge } from "../UI/Badge"; -import { Input } from "../UI/Input"; -import { Select } from "../UI/Select"; -import { Panel } from "../UI/Panel"; - -const extractCity = (address) => { - if (!address || typeof address !== "string") return null; - const trimmed = address.trim(); - if (!trimmed) return null; - - // Patterns: "г.Ялта", "г. Ялта", "г Ялта", "г Ялта ", "Ялта,", "г.Севастополь", etc. - const cityMatch = trimmed.match(/(?:г\.?\s*|г\s+)([А-ЯЁA-Z][а-яёa-zA-Z\s\-]+?)(?:\s*[,;.]|$)/i); - if (cityMatch) { - return cityMatch[1].trim(); - } - - // Try common city names directly in the address - const knownCities = [ - "Севастополь", "Ялта", "Симферополь", "Феодосия", "Евпатория", - "Керчь", "Алушта", "Бахчисарай", "Судак", "Инкерман", - "Джанкой", "Красногвардейское", "Раздольное", "Черноморское", - ]; - for (const city of knownCities) { - if (trimmed.toLowerCase().includes(city.toLowerCase())) { - return city; - } - } - - // Fallback: first comma-separated segment if it looks like a city - const firstSegment = trimmed.split(/[,;]/)[0].trim(); - if (firstSegment.length > 2 && firstSegment.length < 30 && !/^\d/.test(firstSegment)) { - return firstSegment; - } - - return null; -}; - -const normalizeCity = (address) => { - const city = extractCity(address); - return city || "Севастополь"; -}; - -const DRIVER_DELIVERY_STATUS_OPTIONS = [ - { value: "all", label: "Все статусы" }, - ...DRIVER_VISIBLE_DELIVERY_STATUSES.map((status) => ({ - value: status, - label: status === "driver_assigned" ? "Назначено вам" : getOrderGroupDeliveryStatusLabel(status), - })), -]; - -export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUser }) => { - const [filters, setFilters] = React.useState({ - selectedDate: "", - deliveryStatus: "all", - selectedCity: "", - }); - - const driverOrderGroups = React.useMemo( - () => orderGroups.filter((group) => { - const isVisible = isOrderGroupVisibleToDriver(group); - const isAssignedToMe = currentUser && group.assignedDriverId === currentUser.id; - return isVisible && isAssignedToMe; - }), - [orderGroups, currentUser], - ); - - // Build map of date -> count - const dateDeliveryMap = React.useMemo(() => { - const map = new Map(); - driverOrderGroups.forEach((group) => { - const date = group.deliveryDate; - if (date) { - map.set(date, (map.get(date) || 0) + 1); - } - }); - return map; - }, [driverOrderGroups]); - - const sortedDeliveryDates = React.useMemo(() => { - return Array.from(dateDeliveryMap.keys()).sort(); - }, [dateDeliveryMap]); - - // Build map of city -> count - const cityDeliveryMap = React.useMemo(() => { - const map = new Map(); - driverOrderGroups.forEach((group) => { - const city = normalizeCity(group.deliveryAddress || group.delivery_address); - map.set(city, (map.get(city) || 0) + 1); - }); - return map; - }, [driverOrderGroups]); - - const sortedCities = React.useMemo(() => { - return Array.from(cityDeliveryMap.keys()).sort((a, b) => { - // Севастополь first, then alphabetical - if (a === "Севастополь") return -1; - if (b === "Севастополь") return 1; - return a.localeCompare(b, "ru"); - }); - }, [cityDeliveryMap]); - - const filteredOrderGroups = React.useMemo(() => { - let result = [...driverOrderGroups]; - if (filters.selectedDate) { - result = result.filter((group) => group.deliveryDate === filters.selectedDate); - } - if (filters.deliveryStatus !== "all") { - result = result.filter((group) => (group.deliveryStatus || group.delivery_status) === filters.deliveryStatus); - } - if (filters.selectedCity) { - result = result.filter((group) => { - const city = normalizeCity(group.deliveryAddress || group.delivery_address); - return city === filters.selectedCity; - }); - } - return result; - }, [driverOrderGroups, filters.selectedDate, filters.deliveryStatus, filters.selectedCity]); - - const groupedOrderGroups = React.useMemo( - () => groupOrderGroupsByDate(filteredOrderGroups), - [filteredOrderGroups], - ); - - const deliveryCountLabel = `${filteredOrderGroups.length} ${ - filteredOrderGroups.length === 1 ? "доставка" : filteredOrderGroups.length < 5 ? "доставки" : "доставок" - }`; - - const isDateSelected = (date) => filters.selectedDate === date; - - return ( -
- -
-
-
-
-

Мои доставки

- {deliveryCountLabel} -
-

- Показываем только назначенные вам группы доставки. Выберите дату и город. -

-
-
- -
- - -
- - {/* Date pills */} - {sortedDeliveryDates.length > 0 && ( -
- - {sortedDeliveryDates.map((date) => { - const count = dateDeliveryMap.get(date) || 0; - const selected = isDateSelected(date); - return ( - - ); - })} -
- )} - - {/* City pills */} - {sortedCities.length > 1 && ( -
- - {sortedCities.map((city) => { - const count = cityDeliveryMap.get(city) || 0; - const selected = filters.selectedCity === city; - return ( - - ); - })} -
- )} -
-
- - {groupedOrderGroups.length ? ( - groupedOrderGroups.map((group) => { - // Group items by delivery status within each date - const statusBuckets = new Map(); - for (const item of group.items) { - const s = item.deliveryStatus || item.delivery_status || "unknown"; - const label = s === "driver_assigned" ? "Назначено вам" : getOrderGroupDeliveryStatusLabel(s); - const tone = getOrderGroupDeliveryStatusTone(s); - if (!statusBuckets.has(s)) { - statusBuckets.set(s, { label, tone, items: [] }); - } - statusBuckets.get(s).items.push(item); - } - - // Sort status buckets in driver-relevant order - const statusOrder = ["driver_assigned", "loaded", "on_route", "delivered", "problem"]; - const sortedBuckets = Array.from(statusBuckets.entries()).sort(([a], [b]) => { - const ia = statusOrder.indexOf(a); - const ib = statusOrder.indexOf(b); - if (ia === -1 && ib === -1) return a.localeCompare(b); - if (ia === -1) return 1; - if (ib === -1) return -1; - return ia - ib; - }); - - return ( - -
-
-

- {parseGroupDate(group.date)?.toLocaleDateString("ru-RU", { - day: "numeric", - month: "long", - weekday: "long", - }) || "Без даты"} -

-

- {group.items.length} {group.items.length === 1 ? "группа" : "группы"} -

-
- - {(() => { - const d = parseGroupDate(group.date); - if (!d) return group.date || "—"; - const day = String(d.getDate()).padStart(2, "0"); - const month = String(d.getMonth() + 1).padStart(2, "0"); - const year = d.getFullYear(); - return `${day}.${month}.${year}`; - })()} - -
- - {sortedBuckets.map(([statusValue, { label, tone, items }]) => ( -
-
- {label} - {items.length} {items.length === 1 ? "группа" : "группы"} -
-
- {items.map((item) => ( - - ))} -
-
- ))} -
- ); - }) - ) : ( - -

Доставки не найдены

-

- Сейчас у вас нет назначенных групп доставки. -

-
- )} -
- ); -}; diff --git a/supabase/log_order_group_change.sql b/supabase/log_order_group_change.sql new file mode 100644 index 0000000..ea8e29b --- /dev/null +++ b/supabase/log_order_group_change.sql @@ -0,0 +1,123 @@ +CREATE OR REPLACE FUNCTION public.log_order_group_change() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path TO 'public' +AS $$ +DECLARE + v_user_id uuid; + v_changes jsonb := '{}'::jsonb; + v_action text; + v_source text; +BEGIN + -- Determine who made the change + v_user_id := auth.uid(); + v_source := CASE WHEN v_user_id IS NOT NULL THEN 'user' ELSE 'system' END; + + -- Track only meaningful field changes + IF NEW.notification_status IS DISTINCT FROM OLD.notification_status THEN + v_changes := v_changes || jsonb_build_object( + 'notification_status', + jsonb_build_object('old', OLD.notification_status, 'new', NEW.notification_status) + ); + END IF; + + IF NEW.delivery_status IS DISTINCT FROM OLD.delivery_status THEN + v_changes := v_changes || jsonb_build_object( + 'delivery_status', + jsonb_build_object('old', OLD.delivery_status, 'new', NEW.delivery_status) + ); + END IF; + + IF NEW.status IS DISTINCT FROM OLD.status THEN + v_changes := v_changes || jsonb_build_object( + 'status', + jsonb_build_object('old', OLD.status, 'new', NEW.status) + ); + END IF; + + IF NEW.assigned_driver_id IS DISTINCT FROM OLD.assigned_driver_id THEN + v_changes := v_changes || jsonb_build_object( + 'assigned_driver_id', + jsonb_build_object('old', OLD.assigned_driver_id, 'new', NEW.assigned_driver_id) + ); + END IF; + + IF NEW.delivery_link IS DISTINCT FROM OLD.delivery_link THEN + v_changes := v_changes || jsonb_build_object( + 'delivery_link', + jsonb_build_object('old', OLD.delivery_link, 'new', NEW.delivery_link) + ); + END IF; + + IF NEW.first_sms_sent_at IS DISTINCT FROM OLD.first_sms_sent_at THEN + v_changes := v_changes || jsonb_build_object( + 'first_sms_sent_at', + jsonb_build_object('old', OLD.first_sms_sent_at, 'new', NEW.first_sms_sent_at) + ); + END IF; + + IF NEW.second_sms_sent_at IS DISTINCT FROM OLD.second_sms_sent_at THEN + v_changes := v_changes || jsonb_build_object( + 'second_sms_sent_at', + jsonb_build_object('old', OLD.second_sms_sent_at, 'new', NEW.second_sms_sent_at) + ); + END IF; + + IF NEW.last_sms_error IS DISTINCT FROM OLD.last_sms_error THEN + v_changes := v_changes || jsonb_build_object( + 'last_sms_error', + jsonb_build_object('old', OLD.last_sms_error, 'new', NEW.last_sms_error) + ); + END IF; + + -- Only insert if something meaningful changed + IF v_changes = '{}'::jsonb THEN + RETURN NEW; + END IF; + + -- Determine action label + v_action := CASE + WHEN NEW.delivery_status IS DISTINCT FROM OLD.delivery_status THEN 'delivery_status_change' + WHEN NEW.notification_status IS DISTINCT FROM OLD.notification_status THEN 'notification_status_change' + WHEN NEW.assigned_driver_id IS DISTINCT FROM OLD.assigned_driver_id THEN 'driver_assignment_change' + WHEN NEW.delivery_link IS DISTINCT FROM OLD.delivery_link THEN 'delivery_link_change' + ELSE 'field_change' + END; + + INSERT INTO order_history (order_group_id, action, old_status, new_status, user_id, metadata) + VALUES ( + NEW.id, + v_action, + OLD.delivery_status, + NEW.delivery_status, + v_user_id, + jsonb_build_object('changes', v_changes, 'source', v_source, 'group_key', NEW.group_key) + ); + + RETURN NEW; +END; +$$; + +-- Drop old trigger if exists +DROP TRIGGER IF EXISTS log_order_group_change_trigger ON public.order_groups; + +-- Create trigger AFTER UPDATE (only when fields actually change) +CREATE TRIGGER log_order_group_change_trigger +AFTER UPDATE ON public.order_groups +FOR EACH ROW +WHEN ( + NEW.notification_status IS DISTINCT FROM OLD.notification_status + OR NEW.delivery_status IS DISTINCT FROM OLD.delivery_status + OR NEW.status IS DISTINCT FROM OLD.status + OR NEW.assigned_driver_id IS DISTINCT FROM OLD.assigned_driver_id + OR NEW.delivery_link IS DISTINCT FROM OLD.delivery_link + OR NEW.first_sms_sent_at IS DISTINCT FROM OLD.first_sms_sent_at + OR NEW.second_sms_sent_at IS DISTINCT FROM OLD.second_sms_sent_at + OR NEW.last_sms_error IS DISTINCT FROM OLD.last_sms_error +) +EXECUTE FUNCTION public.log_order_group_change(); + +-- Grant access +GRANT SELECT ON order_history TO authenticated; +GRANT INSERT ON order_history TO authenticated; \ No newline at end of file diff --git a/supabase/order_history_rls.sql b/supabase/order_history_rls.sql new file mode 100644 index 0000000..d37e723 --- /dev/null +++ b/supabase/order_history_rls.sql @@ -0,0 +1,24 @@ +-- Drop old policies that reference orders table +DROP POLICY IF EXISTS "history select by order role" ON order_history; +DROP POLICY IF EXISTS "history insert workflow" ON order_history; + +-- New SELECT policy: users can read history for order_groups they have access to +CREATE POLICY "order_history_select" ON order_history +FOR SELECT TO authenticated +USING ( + EXISTS ( + SELECT 1 FROM order_groups og + WHERE og.id = order_history.order_group_id + AND ( + current_role_name() = ANY (ARRAY['admin', 'mega_admin', 'production_lead']) + OR (current_role_name() = 'manager') + OR (current_role_name() = 'logistician') + OR (current_role_name() = 'driver' AND og.assigned_driver_id = auth.uid()) + ) + ) +); + +-- INSERT policy: only trigger (SECURITY DEFINER) inserts, users don't insert directly +CREATE POLICY "order_history_insert_trigger" ON order_history +FOR INSERT TO authenticated +WITH CHECK (true); \ No newline at end of file