refactor: Phase 3 resilience — ErrorBoundary, split saving states, extract useStopWords

- ErrorBoundary: Russian UI, compact mode for card-level errors, reload button
- ErrorBoundary wraps OrderDetailPanel in GroupDetailPage + DeliverySetDetailPanel
- Split isSavingDeliveryChoice into 3 independent states:
  isSavingDeliveryChoice (delivery tab save)
  isSavingDriverAssignment (driver assign)
  isSavingStatusChange (status change)
- Extract useStopWords hook + matchesStopWord to shared hooks/useStopWords.js
- Remove 3x duplicated matchesStopWord from OrderDetailPanel, DriverShipmentPanel, OrderCompositionPanel
- Remove useStopWords + supabase import from OrderDetailPanel
This commit is contained in:
root 2026-06-12 15:47:55 +00:00
parent 129175fed7
commit 2f2dae686c
10 changed files with 120 additions and 88 deletions

View File

@ -12,7 +12,6 @@ class ErrorBoundary extends React.Component {
} }
componentDidCatch(error, errorInfo) { componentDidCatch(error, errorInfo) {
// Extract component stack for richer context
const componentInfo = { const componentInfo = {
component: errorInfo?.componentStack || null, component: errorInfo?.componentStack || null,
props: this.props, props: this.props,
@ -25,47 +24,61 @@ class ErrorBoundary extends React.Component {
this.setState({ hasError: false, error: null }); this.setState({ hasError: false, error: null });
}; };
handleReload = () => {
window.location.reload();
};
renderDefaultFallback() { renderDefaultFallback() {
const { compact } = this.props;
if (compact) {
return (
<div className="rounded-[24px] border border-[var(--color-error)] bg-[var(--color-error-soft)] p-4 text-center">
<p className="text-sm text-[var(--color-text)]">Что-то пошло не так</p>
<button
onClick={this.handleRetry}
className="mt-2 rounded-xl bg-[var(--color-primary)] px-4 py-2 text-sm font-semibold text-white"
>
Попробовать снова
</button>
</div>
);
}
return ( return (
<div <div className="flex min-h-[460px] flex-col items-center justify-center p-8 text-center">
style={{ <div className="mb-4 text-5xl"></div>
display: 'flex', <h2 className="mb-2 text-xl font-semibold text-[var(--color-text)]">
flexDirection: 'column', Что-то пошло не так
alignItems: 'center',
justifyContent: 'center',
padding: '2rem',
textAlign: 'center',
minHeight: '200px',
}}
>
<h2 style={{ marginBottom: '0.5rem', color: '#e53e3e' }}>
Something went wrong
</h2> </h2>
<p style={{ marginBottom: '1rem', color: '#718096', fontSize: '0.9rem' }}> <p className="mb-6 max-w-md text-sm text-[var(--color-text-muted)]">
An unexpected error occurred. You can try again. Произошла непредвиденная ошибка. Попробуйте обновить страницу или вернуться позже.
</p> </p>
<button {this.state.error && process.env.NODE_ENV === 'development' && (
onClick={this.handleRetry} <pre className="mb-4 max-h-32 overflow-auto rounded-xl bg-[var(--color-surface-strong)] p-3 text-left text-xs text-[var(--color-error)]">
style={{ {this.state.error.message}
padding: '0.5rem 1.25rem', </pre>
fontSize: '0.9rem', )}
fontWeight: 600, <div className="flex gap-3">
color: '#fff', <button
backgroundColor: '#3182ce', onClick={this.handleRetry}
border: 'none', className="rounded-xl bg-[var(--color-primary)] px-5 py-2.5 text-sm font-semibold text-white transition-colors hover:opacity-90"
borderRadius: '6px', >
cursor: 'pointer', Попробовать снова
}} </button>
> <button
Try Again onClick={this.handleReload}
</button> className="rounded-xl border border-[var(--color-border)] px-5 py-2.5 text-sm font-semibold text-[var(--color-text)] transition-colors hover:bg-[var(--color-surface)]"
>
Обновить страницу
</button>
</div>
</div> </div>
); );
} }
render() { render() {
if (this.state.hasError) { if (this.state.hasError) {
// Allow custom fallback render function
if (typeof this.props.fallback === 'function') { if (typeof this.props.fallback === 'function') {
return this.props.fallback(this.state.error, this.handleRetry); return this.props.fallback(this.state.error, this.handleRetry);
} }

View File

@ -3,6 +3,7 @@ import { Badge } from "../UI/Badge";
import { Panel } from "../UI/Panel"; import { Panel } from "../UI/Panel";
import { getInvitationReferenceLabel } from "./invitationReference"; import { getInvitationReferenceLabel } from "./invitationReference";
import { supabase } from "../../supabaseClient"; import { supabase } from "../../supabaseClient";
import { matchesStopWord } from "../../hooks/useStopWords";
const flattenOrderProducts = (rawItems) => { const flattenOrderProducts = (rawItems) => {
if (!Array.isArray(rawItems) || rawItems.length === 0) return []; if (!Array.isArray(rawItems) || rawItems.length === 0) return [];
@ -64,12 +65,6 @@ const flattenOrderProducts = (rawItems) => {
return products; return products;
}; };
const matchesStopWord = (name, stopWords) => {
if (!stopWords || !stopWords.length) return false;
const lower = name.toLowerCase();
return stopWords.some((sw) => lower.includes(sw.toLowerCase()));
};
export const OrderCompositionPanel = ({ invitation = {} }) => { export const OrderCompositionPanel = ({ invitation = {} }) => {
const [stopWords, setStopWords] = React.useState([]); const [stopWords, setStopWords] = React.useState([]);
const [stopWordsLoaded, setStopWordsLoaded] = React.useState(false); const [stopWordsLoaded, setStopWordsLoaded] = React.useState(false);

View File

@ -3,12 +3,7 @@ import { supabase } from "../../supabaseClient";
import { Badge } from "../UI/Badge"; import { Badge } from "../UI/Badge";
import { Button } from "../UI/Button"; import { Button } from "../UI/Button";
import { Panel } from "../UI/Panel"; import { Panel } from "../UI/Panel";
import { matchesStopWord } from "../../hooks/useStopWords";
const matchesStopWord = (name, stopWords) => {
if (!stopWords || !stopWords.length) return false;
const lower = name.toLowerCase();
return stopWords.some((sw) => lower.includes(sw.toLowerCase()));
};
const parseOrderItems = (order) => { const parseOrderItems = (order) => {
if (!order) return []; if (!order) return [];

View File

@ -1,5 +1,6 @@
import { Button } from "../UI/Button"; import { Button } from "../UI/Button";
import { OrderDetailPanel } from "../orders/OrderDetailPanel"; import { OrderDetailPanel } from "../orders/OrderDetailPanel";
import ErrorBoundary from "../ErrorBoundary";
export const DeliverySetDetailPanel = ({ deliverySet, onClose }) => { export const DeliverySetDetailPanel = ({ deliverySet, onClose }) => {
if (!deliverySet) { if (!deliverySet) {
@ -8,7 +9,9 @@ export const DeliverySetDetailPanel = ({ deliverySet, onClose }) => {
return ( return (
<div className="space-y-5"> <div className="space-y-5">
<OrderDetailPanel order={deliverySet} /> <ErrorBoundary compact>
<OrderDetailPanel order={deliverySet} />
</ErrorBoundary>
{onClose ? ( {onClose ? (
<div className="flex justify-end"> <div className="flex justify-end">

View File

@ -8,7 +8,7 @@ const DriverAssignmentPanel = ({
order, order,
userRole, userRole,
canManageDelivery, canManageDelivery,
isSavingDeliveryChoice, isSavingDriverAssignment,
selectedDriverId, selectedDriverId,
onDriverSelect, onDriverSelect,
onConfirmDriver, onConfirmDriver,
@ -63,7 +63,7 @@ const DriverAssignmentPanel = ({
onChange={(e) => { onChange={(e) => {
onDriverSelect(e.target.value); onDriverSelect(e.target.value);
}} }}
disabled={isSavingDeliveryChoice} disabled={isSavingDriverAssignment}
> >
<option value="">{order.assignedDriverId ? "Сменить водителя..." : "Выберите водителя..."}</option> <option value="">{order.assignedDriverId ? "Сменить водителя..." : "Выберите водителя..."}</option>
{drivers.map((driver) => ( {drivers.map((driver) => (
@ -73,9 +73,9 @@ const DriverAssignmentPanel = ({
<Button <Button
className="md:px-4 md:py-2 md:whitespace-nowrap md:self-start" className="md:px-4 md:py-2 md:whitespace-nowrap md:self-start"
onClick={onConfirmDriver} onClick={onConfirmDriver}
disabled={isSavingDeliveryChoice || !selectedDriverId} disabled={isSavingDriverAssignment || !selectedDriverId}
> >
{isSavingDeliveryChoice ? "Назначаем..." : "Назначить"} {isSavingDriverAssignment ? "Назначаем..." : "Назначить"}
</Button> </Button>
</div> </div>
) : null} ) : null}

View File

@ -47,7 +47,7 @@ import { DriverShipmentPanel } from "../driver/DriverShipmentPanel";
import { CalendarWidget } from "./CalendarWidget"; import { CalendarWidget } from "./CalendarWidget";
import { StatusActionPanel } from "./StatusActionPanel"; import { StatusActionPanel } from "./StatusActionPanel";
import { DriverAssignmentPanel } from "./DriverAssignmentPanel"; import { DriverAssignmentPanel } from "./DriverAssignmentPanel";
import { supabase } from "../../supabaseClient"; import { matchesStopWord, useStopWords } from "../../hooks/useStopWords";
import { import {
getOrderGroupDeliveryStatusLabel, getOrderGroupDeliveryStatusLabel,
getOrderGroupDisplayStatusLabel, getOrderGroupDisplayStatusLabel,
@ -326,27 +326,7 @@ const normalizeDateForInput = (value) => {
return ""; return "";
}; };
const matchesStopWord = (name, stopWords) => { const renderValue = (value) => value || "Нет данных";
if (!stopWords || !stopWords.length) return false;
const lower = name.toLowerCase();
return stopWords.some((sw) => lower.includes(sw.toLowerCase()));
};
const useStopWords = () => {
const [stopWords, setStopWords] = React.useState([]);
const [active, setActive] = React.useState(true);
React.useEffect(() => {
if (!supabase) return;
Promise.all([
supabase.from("stop_words").select("word").then(r => r.data || []),
supabase.from("stop_words_scope").select("scope").eq("id", 1).single().then(r => r.data),
]).then(([words, scopeRow]) => {
setStopWords(words.map((d) => d.word));
setActive(scopeRow?.scope !== "client_only");
});
}, []);
return { stopWords, active };
};
const CollapsibleOrderComposition = ({ order }) => { const CollapsibleOrderComposition = ({ order }) => {
const [isExpanded, setIsExpanded] = React.useState(false); const [isExpanded, setIsExpanded] = React.useState(false);
@ -431,7 +411,7 @@ const CollapsibleOrderComposition = ({ order }) => {
); );
}; };
const PaidStoragePanel = ({ order, onChangeDeliveryStatus, isSavingDeliveryChoice, setFormMessage }) => { const PaidStoragePanel = ({ order, onChangeDeliveryStatus, isSavingStatusChange, setFormMessage }) => {
const [showConfirm, setShowConfirm] = React.useState(false); const [showConfirm, setShowConfirm] = React.useState(false);
const isPaidStorage = (order.deliveryStatus || order.delivery_status) === "paid_storage"; const isPaidStorage = (order.deliveryStatus || order.delivery_status) === "paid_storage";
@ -461,7 +441,7 @@ const PaidStoragePanel = ({ order, onChangeDeliveryStatus, isSavingDeliveryChoic
} }
}); });
}} }}
disabled={isSavingDeliveryChoice} disabled={isSavingStatusChange}
> >
Отменить платное хранение Отменить платное хранение
</Button> </Button>
@ -497,14 +477,14 @@ const PaidStoragePanel = ({ order, onChangeDeliveryStatus, isSavingDeliveryChoic
} }
}); });
}} }}
disabled={isSavingDeliveryChoice} disabled={isSavingStatusChange}
> >
Да, перевести Да, перевести
</Button> </Button>
<Button <Button
variant="secondary" variant="secondary"
onClick={() => setShowConfirm(false)} onClick={() => setShowConfirm(false)}
disabled={isSavingDeliveryChoice} disabled={isSavingStatusChange}
> >
Отмена Отмена
</Button> </Button>
@ -514,7 +494,7 @@ const PaidStoragePanel = ({ order, onChangeDeliveryStatus, isSavingDeliveryChoic
<Button <Button
variant="secondary" variant="secondary"
onClick={() => setShowConfirm(true)} onClick={() => setShowConfirm(true)}
disabled={isSavingDeliveryChoice} disabled={isSavingStatusChange}
> >
Перевести в платное хранение Перевести в платное хранение
</Button> </Button>
@ -561,6 +541,8 @@ export const OrderDetailPanel = ({
canManageDelivery = false, canManageDelivery = false,
onSaveManualDeliveryChoice, onSaveManualDeliveryChoice,
isSavingDeliveryChoice = false, isSavingDeliveryChoice = false,
isSavingDriverAssignment = false,
isSavingStatusChange = false,
drivers = [], drivers = [],
onAssignDriver, onAssignDriver,
onChangeDeliveryStatus, onChangeDeliveryStatus,
@ -1004,7 +986,7 @@ export const OrderDetailPanel = ({
order={order} order={order}
userRole={userRole} userRole={userRole}
canManageDelivery={canManageDelivery} canManageDelivery={canManageDelivery}
isSavingDeliveryChoice={isSavingDeliveryChoice} isSavingDriverAssignment={isSavingDriverAssignment}
selectedDriverId={selectedDriverId} selectedDriverId={selectedDriverId}
onDriverSelect={(id) => { setSelectedDriverId(id); setDriverMessage(""); }} onDriverSelect={(id) => { setSelectedDriverId(id); setDriverMessage(""); }}
onConfirmDriver={() => setConfirmAction({ type: 'driver' })} onConfirmDriver={() => setConfirmAction({ type: 'driver' })}
@ -1017,7 +999,7 @@ export const OrderDetailPanel = ({
order={order} order={order}
userRole={userRole} userRole={userRole}
canManageDelivery={canManageDelivery} canManageDelivery={canManageDelivery}
isSavingDeliveryChoice={isSavingDeliveryChoice} isSavingStatusChange={isSavingStatusChange}
onConfirmStatus={(action) => { onConfirmStatus={(action) => {
if (action.type === "hint") { if (action.type === "hint") {
setFormMessage(action.hint); setFormMessage(action.hint);
@ -1035,7 +1017,7 @@ export const OrderDetailPanel = ({
<PaidStoragePanel <PaidStoragePanel
order={order} order={order}
onChangeDeliveryStatus={onChangeDeliveryStatus} onChangeDeliveryStatus={onChangeDeliveryStatus}
isSavingDeliveryChoice={isSavingDeliveryChoice} isSavingStatusChange={isSavingStatusChange}
setFormMessage={setFormMessage} setFormMessage={setFormMessage}
/> />
) : null} ) : null}
@ -1112,7 +1094,7 @@ export const OrderDetailPanel = ({
<div className="flex items-center gap-3 mt-2"> <div className="flex items-center gap-3 mt-2">
<Button <Button
variant="primary" variant="primary"
disabled={isSavingDeliveryChoice} disabled={isSavingStatusChange}
onClick={() => { onClick={() => {
if (pendingStatus.value === "delivered" && shipmentState && !shipmentState.canMarkDelivered) return; if (pendingStatus.value === "delivered" && shipmentState && !shipmentState.canMarkDelivered) return;
onChangeDeliveryStatus({ onChangeDeliveryStatus({

View File

@ -10,7 +10,7 @@ const StatusActionPanel = ({
order, order,
userRole, userRole,
canManageDelivery, canManageDelivery,
isSavingDeliveryChoice, isSavingStatusChange,
onConfirmStatus, onConfirmStatus,
}) => { }) => {
if (!canManageDelivery || !["manager", "logistician", "admin", "mega_admin"].includes(userRole) || !order) { if (!canManageDelivery || !["manager", "logistician", "admin", "mega_admin"].includes(userRole) || !order) {
@ -53,7 +53,7 @@ const StatusActionPanel = ({
} }
onConfirmStatus?.({ type: "status", status: statusOption.value }); onConfirmStatus?.({ type: "status", status: statusOption.value });
}} }}
disabled={isSavingDeliveryChoice} disabled={isSavingStatusChange}
> >
{statusOption.label} {statusOption.label}
</Button> </Button>

View File

@ -19,6 +19,8 @@ export const useOrderGroups = () => {
const [isLoading, setIsLoading] = React.useState(true); const [isLoading, setIsLoading] = React.useState(true);
const [loadError, setLoadError] = React.useState(""); const [loadError, setLoadError] = React.useState("");
const [isSavingDeliveryChoice, setIsSavingDeliveryChoice] = React.useState(false); const [isSavingDeliveryChoice, setIsSavingDeliveryChoice] = React.useState(false);
const [isSavingDriverAssignment, setIsSavingDriverAssignment] = React.useState(false);
const [isSavingStatusChange, setIsSavingStatusChange] = React.useState(false);
React.useEffect(() => { React.useEffect(() => {
let cancelled = false; let cancelled = false;
@ -142,7 +144,7 @@ export const useOrderGroups = () => {
}, []); }, []);
const assignDriver = React.useCallback(async ({ orderGroupId, driverId }) => { const assignDriver = React.useCallback(async ({ orderGroupId, driverId }) => {
setIsSavingDeliveryChoice(true); setIsSavingDriverAssignment(true);
try { try {
const result = await assignDriverToOrderGroup({ orderGroupId, driverId }); const result = await assignDriverToOrderGroup({ orderGroupId, driverId });
@ -165,12 +167,12 @@ export const useOrderGroups = () => {
error: getErrorMessage(error, "Не удалось назначить водителя"), error: getErrorMessage(error, "Не удалось назначить водителя"),
}; };
} finally { } finally {
setIsSavingDeliveryChoice(false); setIsSavingDriverAssignment(false);
} }
}, []); }, []);
const changeDeliveryStatus = React.useCallback(async ({ orderGroupId, status, details }) => { const changeDeliveryStatus = React.useCallback(async ({ orderGroupId, status, details }) => {
setIsSavingDeliveryChoice(true); setIsSavingStatusChange(true);
try { try {
const result = await updateDeliveryStatus({ orderGroupId, status, details }); const result = await updateDeliveryStatus({ orderGroupId, status, details });
if (result.error) { if (result.error) {
@ -189,7 +191,7 @@ export const useOrderGroups = () => {
error: getErrorMessage(error, "Не удалось обновить статус"), error: getErrorMessage(error, "Не удалось обновить статус"),
}; };
} finally { } finally {
setIsSavingDeliveryChoice(false); setIsSavingStatusChange(false);
} }
}, []); }, []);
@ -210,6 +212,8 @@ export const useOrderGroups = () => {
assignDriver, assignDriver,
changeDeliveryStatus, changeDeliveryStatus,
isSavingDeliveryChoice, isSavingDeliveryChoice,
isSavingDriverAssignment,
isSavingStatusChange,
isLoading, isLoading,
loadError, loadError,
}; };

33
src/hooks/useStopWords.js Normal file
View File

@ -0,0 +1,33 @@
import React from "react";
import { supabase } from "../supabaseClient";
const matchesStopWord = (name, stopWords) => {
if (!stopWords || !stopWords.length) return false;
const lower = name.toLowerCase();
return stopWords.some((sw) => lower.includes(sw.toLowerCase()));
};
const useStopWords = () => {
const [stopWords, setStopWords] = React.useState([]);
const [active, setActive] = React.useState(true);
React.useEffect(() => {
if (!supabase) return;
Promise.all([
supabase.from("stop_words").select("word").then((r) => r.data || []),
supabase
.from("stop_words_scope")
.select("scope")
.eq("id", 1)
.single()
.then((r) => r.data),
]).then(([words, scopeRow]) => {
setStopWords(words.map((d) => d.word));
setActive(scopeRow?.scope !== "client_only");
});
}, []);
return { stopWords, active };
};
export { matchesStopWord, useStopWords };

View File

@ -6,6 +6,7 @@
import React from "react"; import React from "react";
import { Navigate, useNavigate, useParams, useLocation } from "react-router-dom"; import { Navigate, useNavigate, useParams, useLocation } from "react-router-dom";
import { OrderDetailPanel } from "../components/orders/OrderDetailPanel"; import { OrderDetailPanel } from "../components/orders/OrderDetailPanel";
import ErrorBoundary from "../components/ErrorBoundary";
import { Button } from "../components/UI/Button"; import { Button } from "../components/UI/Button";
import { Panel } from "../components/UI/Panel"; import { Panel } from "../components/UI/Panel";
import { SkeletonPanel } from "../components/UI/Loading"; import { SkeletonPanel } from "../components/UI/Loading";
@ -30,6 +31,8 @@ export const GroupDetailPage = () => {
setSelectedOrderGroupId, setSelectedOrderGroupId,
saveManualDeliveryChoice, saveManualDeliveryChoice,
isSavingDeliveryChoice, isSavingDeliveryChoice,
isSavingDriverAssignment,
isSavingStatusChange,
assignDriver, assignDriver,
changeDeliveryStatus, changeDeliveryStatus,
isLoading, isLoading,
@ -97,16 +100,20 @@ export const GroupDetailPage = () => {
{isLoading ? ( {isLoading ? (
<SkeletonPanel lines={6} /> <SkeletonPanel lines={6} />
) : order ? ( ) : order ? (
<OrderDetailPanel <ErrorBoundary compact>
<OrderDetailPanel
order={order} order={order}
canManageDelivery={["manager", "logistician", "admin", "mega_admin"].includes(userRole)} canManageDelivery={["manager", "logistician", "admin", "mega_admin"].includes(userRole)}
onSaveManualDeliveryChoice={saveManualDeliveryChoice} onSaveManualDeliveryChoice={saveManualDeliveryChoice}
isSavingDeliveryChoice={isSavingDeliveryChoice} isSavingDeliveryChoice={isSavingDeliveryChoice}
isSavingDriverAssignment={isSavingDriverAssignment}
isSavingStatusChange={isSavingStatusChange}
drivers={drivers} drivers={drivers}
onAssignDriver={assignDriver} onAssignDriver={assignDriver}
onChangeDeliveryStatus={changeDeliveryStatus} onChangeDeliveryStatus={changeDeliveryStatus}
userRole={userRole} userRole={userRole}
/> />
</ErrorBoundary>
) : ( ) : (
<Panel className="p-6 text-sm text-[var(--color-text-muted)]"> <Panel className="p-6 text-sm text-[var(--color-text-muted)]">
Группа не найдена. Группа не найдена.