perf: hide-completed server filter + in-memory list cache for fast return-to-list

This commit is contained in:
Hermes 2026-08-19 11:58:20 +02:00
parent 8131e26c77
commit a3cd0838f0
4 changed files with 117 additions and 18 deletions

View File

@ -302,7 +302,7 @@ const isFilterActive = (filters) => {
return false; return false;
}; };
export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusOptions = ORDER_GROUP_DISPLAY_STATUS_OPTIONS, isLoading = false }) => { export const LogisticsReadinessBoard = ({ orderGroups = [], totalGroups: totalGroupsProp, hideCompleted = true, onToggleHideCompleted, onSelectSet, statusOptions = ORDER_GROUP_DISPLAY_STATUS_OPTIONS, isLoading = false }) => {
const FILTERS_KEY = 'logistics-board-filters'; const FILTERS_KEY = 'logistics-board-filters';
const [filters, setFilters] = React.useState(() => { const [filters, setFilters] = React.useState(() => {
try { try {
@ -487,7 +487,27 @@ export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusO
Перетаскивайте секции за ручку слева, чтобы изменить порядок отображения. Перетаскивайте секции за ручку слева, чтобы изменить порядок отображения.
</p> </p>
</div> </div>
<Badge tone="neutral">{totalGroups} групп</Badge> <div className="flex items-center gap-2">
<Badge tone="neutral">{totalGroupsProp ?? totalGroups} групп</Badge>
{typeof onToggleHideCompleted === "function" && (
<button
type="button"
onClick={() => onToggleHideCompleted((prev) => !prev)}
title={hideCompleted ? "Показать доставленные/отгруженные" : "Скрыть доставленные/отгруженные"}
className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium transition ${
hideCompleted
? "border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text)]"
: "border-[var(--color-accent)] bg-[var(--color-accent-soft)] text-[var(--color-accent)]"
}`}
>
<span
aria-hidden="true"
className={`inline-block h-2 w-2 rounded-full ${hideCompleted ? "bg-[var(--color-accent)]" : "bg-[var(--color-text-muted)]"}`}
/>
{hideCompleted ? "Скрыть завершённые" : "Показать завершённые"}
</button>
)}
</div>
</div> </div>
<OrderFilters <OrderFilters

View File

@ -1,5 +1,5 @@
import React from "react"; import React from "react";
import { assignDriverToOrderGroup, fetchOrderGroupById, fetchOrderGroups, saveShipmentData, updateDeliveryStatus, updateOrderGroupDeliveryChoice } from "../services/supabase/orderGroupRepository"; import { assignDriverToOrderGroup, fetchOrderGroupById, fetchOrderGroups, readOrderGroupsCache, saveShipmentData, updateDeliveryStatus, updateOrderGroupDeliveryChoice } from "../services/supabase/orderGroupRepository";
import { import {
buildOrderGroupBuckets, buildOrderGroupBuckets,
filterOrderGroups, filterOrderGroups,
@ -9,8 +9,21 @@ import {
} from "../services/orderGroupViews"; } from "../services/orderGroupViews";
import { getErrorMessage } from "../utils/deliveryUtils"; import { getErrorMessage } from "../utils/deliveryUtils";
export const useOrderGroups = ({ enabled = true, details = false, groupId = null } = {}) => { export const useOrderGroups = ({
const [orderGroups, setOrderGroups] = React.useState(() => []); enabled = true,
details = false,
groupId = null,
hideCompleted = true,
} = {}) => {
const [orderGroups, setOrderGroups] = React.useState(() => {
// Instant paint from cache on return-to-list, then background refresh.
const cached = readOrderGroupsCache(details, hideCompleted);
return cached?.data || [];
});
const [totalGroups, setTotalGroups] = React.useState(() => {
const cached = readOrderGroupsCache(details, hideCompleted);
return cached?.total || 0;
});
const FILTERS_STORAGE_KEY = "supersam_order_filters"; const FILTERS_STORAGE_KEY = "supersam_order_filters";
const [filters, setFilters] = React.useState(() => { const [filters, setFilters] = React.useState(() => {
try { try {
@ -33,7 +46,7 @@ export const useOrderGroups = ({ enabled = true, details = false, groupId = null
const [isSavingDriverAssignment, setIsSavingDriverAssignment] = React.useState(false); const [isSavingDriverAssignment, setIsSavingDriverAssignment] = React.useState(false);
const [isSavingStatusChange, setIsSavingStatusChange] = React.useState(false); const [isSavingStatusChange, setIsSavingStatusChange] = React.useState(false);
const loadLiveData = React.useCallback(async (showLoading = true) => { const loadLiveData = React.useCallback(async (showLoading = true, opts = {}) => {
if (showLoading) { if (showLoading) {
setIsLoading(true); setIsLoading(true);
} }
@ -59,7 +72,7 @@ export const useOrderGroups = ({ enabled = true, details = false, groupId = null
return; return;
} }
const groupsResult = await fetchOrderGroups({ details }); const groupsResult = await fetchOrderGroups({ details, hideCompleted, forceRefresh: opts?.forceRefresh });
if (!groupsResult) { if (!groupsResult) {
return; return;
@ -72,16 +85,20 @@ export const useOrderGroups = ({ enabled = true, details = false, groupId = null
return; return;
} }
setOrderGroups(groupsResult.data || []); const payload = groupsResult.data || {};
setOrderGroups(payload.data || []);
setTotalGroups(payload.total ?? (payload.data?.length || 0));
setIsLoading(false); setIsLoading(false);
}, [enabled, details, groupId]); }, [enabled, details, groupId, hideCompleted]);
// Initial load // Initial load
React.useEffect(() => { React.useEffect(() => {
let cancelled = false; let cancelled = false;
const doLoad = async () => { const doLoad = async () => {
setIsLoading(true); const hasCache = !!readOrderGroupsCache(details, hideCompleted);
await loadLiveData(true); // If we seeded from cache, don't flash the spinner — refresh silently in bg.
setIsLoading(!hasCache);
await loadLiveData(!hasCache, { forceRefresh: true });
if (cancelled) return; if (cancelled) return;
}; };
if (enabled) doLoad(); else { setOrderGroups([]); setIsLoading(false); } if (enabled) doLoad(); else { setOrderGroups([]); setIsLoading(false); }
@ -262,6 +279,7 @@ export const useOrderGroups = ({ enabled = true, details = false, groupId = null
return { return {
orderGroups, orderGroups,
allOrderGroups: orderGroups, allOrderGroups: orderGroups,
totalGroups,
filteredOrderGroups, filteredOrderGroups,
visibleOrderGroups, visibleOrderGroups,
selectedOrderGroup, selectedOrderGroup,

View File

@ -91,6 +91,14 @@ export const DashboardPage = () => {
const { isInstalled, isInstallAvailable, installApp: onInstallApp } = usePwaStatus(); const { isInstalled, isInstallAvailable, installApp: onInstallApp } = usePwaStatus();
// ── Order Groups ───────────────────────────────────────────────────────── // ── Order Groups ─────────────────────────────────────────────────────────
const HIDE_COMPLETED_KEY = "supersam-hide-completed";
const [hideCompleted, setHideCompleted] = React.useState(() => {
try { return localStorage.getItem(HIDE_COMPLETED_KEY) !== "false"; } catch { return true; }
});
React.useEffect(() => {
try { localStorage.setItem(HIDE_COMPLETED_KEY, hideCompleted ? "true" : "false"); } catch {}
}, [hideCompleted]);
const { const {
orderGroups, orderGroups,
allOrderGroups, allOrderGroups,
@ -102,7 +110,8 @@ export const DashboardPage = () => {
statusOptions, statusOptions,
isLoading, isLoading,
loadError, loadError,
} = useOrderGroups({ enabled: ["orders", "logistics", "deliveries"].includes(activeSection), details: false }); totalGroups,
} = useOrderGroups({ enabled: ["orders", "logistics", "deliveries"].includes(activeSection), details: false, hideCompleted });
// ── Derived City List ───────────────────────────────────────────────────── // ── Derived City List ─────────────────────────────────────────────────────
const cities = React.useMemo(() => { const cities = React.useMemo(() => {
@ -200,7 +209,7 @@ const ALLOWED_DASHBOARD_ROLES = ["admin", "mega_admin", "manager", "logistician"
// All roles (logistician, admin, mega_admin, manager) — same grouped board // All roles (logistician, admin, mega_admin, manager) — same grouped board
return ( return (
<div className="space-y-6 xl:space-y-8"> <div className="space-y-6 xl:space-y-8">
<LogisticsReadinessBoard orderGroups={allOrderGroups} onSelectSet={openGroupPage} statusOptions={statusOptions} isLoading={isLoading} /> <LogisticsReadinessBoard orderGroups={allOrderGroups} totalGroups={totalGroups} hideCompleted={hideCompleted} onToggleHideCompleted={setHideCompleted} onSelectSet={openGroupPage} statusOptions={statusOptions} isLoading={isLoading} />
</div> </div>
); );
}; };

View File

@ -289,7 +289,10 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => {
}; };
const ORDER_GROUP_SELECT_FIELDS = `id, group_key, order_numbers, status, delivery_status, sms_sent_at, created_at, updated_at, created_from_exchange_at, source_key, town, customer_name, customer_phone, customer_phone_normalized, customer_date, orders_total, orders_ready, orders_not_ready, source_orders, order_list, order_list_structured, delivery_invitation_id, delivery_link, delivery_link_code, notification_status, sms_attempts, first_sms_sent_at, second_sms_sent_at, last_sms_error, next_notification_check_at, delivery_date, delivery_time, delivery_address, customer_address, delivery_date_source, manual_confirmation_at, paid_storage_at, assigned_driver_id, assigned_driver:users!order_groups_assigned_driver_id_fkey(id, name), driver_shipment_data, delivery_type, pickup_date, pickup_time_slot, has_delivery_problem, delivery_problem_note, pickup_code, synced_to_1c_at, manager_name, manager_email, manager_tel, payed_ship, is_payed_ship, delivery_invitation:delivery_invitations!order_groups_delivery_invitation_id_fkey(opened_at, access_count, last_accessed_at), called, called_at, called_comment, order_history(id, action, old_status, new_status, user_id, metadata, created_at)`; const ORDER_GROUP_SELECT_FIELDS = `id, group_key, order_numbers, status, delivery_status, sms_sent_at, created_at, updated_at, created_from_exchange_at, source_key, town, customer_name, customer_phone, customer_phone_normalized, customer_date, orders_total, orders_ready, orders_not_ready, source_orders, order_list, order_list_structured, delivery_invitation_id, delivery_link, delivery_link_code, notification_status, sms_attempts, first_sms_sent_at, second_sms_sent_at, last_sms_error, next_notification_check_at, delivery_date, delivery_time, delivery_address, customer_address, delivery_date_source, manual_confirmation_at, paid_storage_at, assigned_driver_id, assigned_driver:users!order_groups_assigned_driver_id_fkey(id, name), driver_shipment_data, delivery_type, pickup_date, pickup_time_slot, has_delivery_problem, delivery_problem_note, pickup_code, synced_to_1c_at, manager_name, manager_email, manager_tel, payed_ship, is_payed_ship, delivery_invitation:delivery_invitations!order_groups_delivery_invitation_id_fkey(opened_at, access_count, last_accessed_at), called, called_at, called_comment, order_history(id, action, old_status, new_status, user_id, metadata, created_at)`;
const ORDER_GROUP_LIST_SELECT_FIELDS = `id, group_key, order_numbers, status, delivery_status, sms_sent_at, created_at, updated_at, created_from_exchange_at, source_key, town, customer_name, customer_phone, customer_phone_normalized, customer_date, orders_total, orders_ready, orders_not_ready, notification_status, sms_attempts, last_sms_error, first_sms_sent_at, second_sms_sent_at, delivery_date, delivery_time, delivery_address, customer_address, delivery_date_source, manual_confirmation_at, paid_storage_at, assigned_driver_id, assigned_driver:users!order_groups_assigned_driver_id_fkey(id, name), driver_shipment_data, delivery_type, pickup_date, pickup_time_slot, has_delivery_problem, delivery_problem_note, pickup_code, synced_to_1c_at, manager_name, manager_email, manager_tel, payed_ship, is_payed_ship, called, called_at, called_comment`; export const ORDER_GROUP_LIST_SELECT_FIELDS = `id, group_key, order_numbers, status, delivery_status, sms_sent_at, created_at, updated_at, created_from_exchange_at, source_key, town, customer_name, customer_phone, customer_phone_normalized, customer_date, orders_total, orders_ready, orders_not_ready, notification_status, sms_attempts, last_sms_error, first_sms_sent_at, second_sms_sent_at, delivery_date, delivery_time, delivery_address, customer_address, delivery_date_source, manual_confirmation_at, paid_storage_at, assigned_driver_id, assigned_driver:users!order_groups_assigned_driver_id_fkey(id, name), driver_shipment_data, delivery_type, pickup_date, pickup_time_slot, has_delivery_problem, delivery_problem_note, pickup_code, synced_to_1c_at, manager_name, manager_email, manager_tel, payed_ship, is_payed_ship, called, called_at, called_comment`;
// Delivery statuses treated as "finished" — hidden by default in the board.
const COMPLETED_DELIVERY_STATUSES = ["delivered", "picked_up", "cancelled"];
export const updateOrderGroupDeliveryChoice = async ({ export const updateOrderGroupDeliveryChoice = async ({
orderGroupId, orderGroupId,
@ -300,6 +303,7 @@ export const updateOrderGroupDeliveryChoice = async ({
pickupDate, pickupDate,
pickupTimeSlot, pickupTimeSlot,
}) => { }) => {
orderGroupsCache.clear();
return safeSupabaseCall(async () => { return safeSupabaseCall(async () => {
const client = requireSupabase(); const client = requireSupabase();
const effectiveDeliveryStatus = deliveryType === "pickup" ? "pickup" : "agreed"; const effectiveDeliveryStatus = deliveryType === "pickup" ? "pickup" : "agreed";
@ -354,6 +358,7 @@ export const assignDriverToOrderGroup = async ({
orderGroupId, orderGroupId,
driverId, driverId,
}) => { }) => {
orderGroupsCache.clear();
return safeSupabaseCall(async () => { return safeSupabaseCall(async () => {
const client = requireSupabase(); const client = requireSupabase();
@ -416,6 +421,7 @@ export const assignDriverToOrderGroup = async ({
}; };
export const saveShipmentData = async ({ orderGroupId, shipmentData }) => { export const saveShipmentData = async ({ orderGroupId, shipmentData }) => {
orderGroupsCache.clear();
return safeSupabaseCall(async () => { return safeSupabaseCall(async () => {
const client = requireSupabase(); const client = requireSupabase();
const hasAnyShipped = shipmentData.some((i) => i.shipped); const hasAnyShipped = shipmentData.some((i) => i.shipped);
@ -472,6 +478,7 @@ export const saveShipmentData = async ({ orderGroupId, shipmentData }) => {
}; };
export const updateDeliveryStatus = async ({ orderGroupId, status, details, shipmentData } = {}) => { export const updateDeliveryStatus = async ({ orderGroupId, status, details, shipmentData } = {}) => {
orderGroupsCache.clear();
return safeSupabaseCall(async () => { return safeSupabaseCall(async () => {
const client = requireSupabase(); const client = requireSupabase();
@ -566,14 +573,55 @@ export const updateDeliveryStatus = async ({ orderGroupId, status, details, ship
}, "Ошибка обновления статуса доставки"); }, "Ошибка обновления статуса доставки");
}; };
export const fetchOrderGroups = async ({ details = true } = {}) => { // Module-level cache so returning to the list from a card doesn't refetch the
// whole board (the previous "return to list is slow" annoyance). Keyed by details
// + hideCompleted. The hook refetches silently in the background to stay fresh.
const orderGroupsCache = new Map();
const orderGroupsCacheKey = (details, hideCompleted) =>
`v1:${details ? "detail" : "list"}:${hideCompleted ? "hide" : "all"}`;
/** Synchronous read of the cached board list ({ data, total } | null). */
export const readOrderGroupsCache = (details = false, hideCompleted = true) =>
orderGroupsCache.get(orderGroupsCacheKey(details, hideCompleted)) || null;
/**
* Fetch order groups.
*
* Returns { data, total } where total is the full count matching the filter
* (requires `count` support from PostgREST — available on self-hosted).
*
* @param {object} options
* @param {boolean} [options.details=false] Load full detail fields (for detail pages)
* @param {boolean} [options.hideCompleted=true] Exclude delivered/picked_up/cancelled groups
* @param {boolean} [options.forceRefresh=false] Bypass the in-memory cache and refetch
*/
export const fetchOrderGroups = async ({
details = false,
hideCompleted = true,
forceRefresh = false,
} = {}) => {
const cacheKey = orderGroupsCacheKey(details, hideCompleted);
if (!forceRefresh) {
const cached = orderGroupsCache.get(cacheKey);
if (cached) return cached;
}
return safeSupabaseCall(async () => { return safeSupabaseCall(async () => {
const client = requireSupabase(); const client = requireSupabase();
const { data, error } = await client
let query = client
.from("order_groups") .from("order_groups")
.select(details ? ORDER_GROUP_SELECT_FIELDS : ORDER_GROUP_LIST_SELECT_FIELDS) .select(details ? ORDER_GROUP_SELECT_FIELDS : ORDER_GROUP_LIST_SELECT_FIELDS, { count: "exact" })
.order("updated_at", { ascending: false }); .order("updated_at", { ascending: false });
if (hideCompleted) {
query = query.not("delivery_status", "in", `(${COMPLETED_DELIVERY_STATUSES.join(",")})`);
}
const { data, error, count } = await query;
if (error) { if (error) {
throw error; throw error;
} }
@ -585,13 +633,17 @@ export const fetchOrderGroups = async ({ details = true } = {}) => {
drivers.forEach((d) => driverMap.set(d.id, d.name || d.email)); drivers.forEach((d) => driverMap.set(d.id, d.name || d.email));
} }
return (data || []).map((row) => { const groups = (data || []).map((row) => {
const group = mapOrderGroupRowToDeliveryGroup(row); const group = mapOrderGroupRowToDeliveryGroup(row);
if (group && group.assignedDriverId && !group.assignedDriverName) { if (group && group.assignedDriverId && !group.assignedDriverName) {
group.assignedDriverName = driverMap.get(group.assignedDriverId) || ""; group.assignedDriverName = driverMap.get(group.assignedDriverId) || "";
} }
return group; return group;
}).filter(Boolean); }).filter(Boolean);
const result = { data: groups, total: count ?? groups.length };
orderGroupsCache.set(cacheKey, result);
return result;
}, "Ошибка загрузки групп доставки"); }, "Ошибка загрузки групп доставки");
}; };