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

View File

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

View File

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

View File

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

View File

@ -47,7 +47,7 @@ import { DriverShipmentPanel } from "../driver/DriverShipmentPanel";
import { CalendarWidget } from "./CalendarWidget";
import { StatusActionPanel } from "./StatusActionPanel";
import { DriverAssignmentPanel } from "./DriverAssignmentPanel";
import { supabase } from "../../supabaseClient";
import { matchesStopWord, useStopWords } from "../../hooks/useStopWords";
import {
getOrderGroupDeliveryStatusLabel,
getOrderGroupDisplayStatusLabel,
@ -326,27 +326,7 @@ const normalizeDateForInput = (value) => {
return "";
};
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 };
};
const renderValue = (value) => value || "Нет данных";
const CollapsibleOrderComposition = ({ order }) => {
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 isPaidStorage = (order.deliveryStatus || order.delivery_status) === "paid_storage";
@ -461,7 +441,7 @@ const PaidStoragePanel = ({ order, onChangeDeliveryStatus, isSavingDeliveryChoic
}
});
}}
disabled={isSavingDeliveryChoice}
disabled={isSavingStatusChange}
>
Отменить платное хранение
</Button>
@ -497,14 +477,14 @@ const PaidStoragePanel = ({ order, onChangeDeliveryStatus, isSavingDeliveryChoic
}
});
}}
disabled={isSavingDeliveryChoice}
disabled={isSavingStatusChange}
>
Да, перевести
</Button>
<Button
variant="secondary"
onClick={() => setShowConfirm(false)}
disabled={isSavingDeliveryChoice}
disabled={isSavingStatusChange}
>
Отмена
</Button>
@ -514,7 +494,7 @@ const PaidStoragePanel = ({ order, onChangeDeliveryStatus, isSavingDeliveryChoic
<Button
variant="secondary"
onClick={() => setShowConfirm(true)}
disabled={isSavingDeliveryChoice}
disabled={isSavingStatusChange}
>
Перевести в платное хранение
</Button>
@ -561,6 +541,8 @@ export const OrderDetailPanel = ({
canManageDelivery = false,
onSaveManualDeliveryChoice,
isSavingDeliveryChoice = false,
isSavingDriverAssignment = false,
isSavingStatusChange = false,
drivers = [],
onAssignDriver,
onChangeDeliveryStatus,
@ -1004,7 +986,7 @@ export const OrderDetailPanel = ({
order={order}
userRole={userRole}
canManageDelivery={canManageDelivery}
isSavingDeliveryChoice={isSavingDeliveryChoice}
isSavingDriverAssignment={isSavingDriverAssignment}
selectedDriverId={selectedDriverId}
onDriverSelect={(id) => { setSelectedDriverId(id); setDriverMessage(""); }}
onConfirmDriver={() => setConfirmAction({ type: 'driver' })}
@ -1017,7 +999,7 @@ export const OrderDetailPanel = ({
order={order}
userRole={userRole}
canManageDelivery={canManageDelivery}
isSavingDeliveryChoice={isSavingDeliveryChoice}
isSavingStatusChange={isSavingStatusChange}
onConfirmStatus={(action) => {
if (action.type === "hint") {
setFormMessage(action.hint);
@ -1035,7 +1017,7 @@ export const OrderDetailPanel = ({
<PaidStoragePanel
order={order}
onChangeDeliveryStatus={onChangeDeliveryStatus}
isSavingDeliveryChoice={isSavingDeliveryChoice}
isSavingStatusChange={isSavingStatusChange}
setFormMessage={setFormMessage}
/>
) : null}
@ -1112,7 +1094,7 @@ export const OrderDetailPanel = ({
<div className="flex items-center gap-3 mt-2">
<Button
variant="primary"
disabled={isSavingDeliveryChoice}
disabled={isSavingStatusChange}
onClick={() => {
if (pendingStatus.value === "delivered" && shipmentState && !shipmentState.canMarkDelivered) return;
onChangeDeliveryStatus({

View File

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

View File

@ -19,6 +19,8 @@ export const useOrderGroups = () => {
const [isLoading, setIsLoading] = React.useState(true);
const [loadError, setLoadError] = React.useState("");
const [isSavingDeliveryChoice, setIsSavingDeliveryChoice] = React.useState(false);
const [isSavingDriverAssignment, setIsSavingDriverAssignment] = React.useState(false);
const [isSavingStatusChange, setIsSavingStatusChange] = React.useState(false);
React.useEffect(() => {
let cancelled = false;
@ -142,7 +144,7 @@ export const useOrderGroups = () => {
}, []);
const assignDriver = React.useCallback(async ({ orderGroupId, driverId }) => {
setIsSavingDeliveryChoice(true);
setIsSavingDriverAssignment(true);
try {
const result = await assignDriverToOrderGroup({ orderGroupId, driverId });
@ -165,12 +167,12 @@ export const useOrderGroups = () => {
error: getErrorMessage(error, "Не удалось назначить водителя"),
};
} finally {
setIsSavingDeliveryChoice(false);
setIsSavingDriverAssignment(false);
}
}, []);
const changeDeliveryStatus = React.useCallback(async ({ orderGroupId, status, details }) => {
setIsSavingDeliveryChoice(true);
setIsSavingStatusChange(true);
try {
const result = await updateDeliveryStatus({ orderGroupId, status, details });
if (result.error) {
@ -189,7 +191,7 @@ export const useOrderGroups = () => {
error: getErrorMessage(error, "Не удалось обновить статус"),
};
} finally {
setIsSavingDeliveryChoice(false);
setIsSavingStatusChange(false);
}
}, []);
@ -210,6 +212,8 @@ export const useOrderGroups = () => {
assignDriver,
changeDeliveryStatus,
isSavingDeliveryChoice,
isSavingDriverAssignment,
isSavingStatusChange,
isLoading,
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 { Navigate, useNavigate, useParams, useLocation } from "react-router-dom";
import { OrderDetailPanel } from "../components/orders/OrderDetailPanel";
import ErrorBoundary from "../components/ErrorBoundary";
import { Button } from "../components/UI/Button";
import { Panel } from "../components/UI/Panel";
import { SkeletonPanel } from "../components/UI/Loading";
@ -30,6 +31,8 @@ export const GroupDetailPage = () => {
setSelectedOrderGroupId,
saveManualDeliveryChoice,
isSavingDeliveryChoice,
isSavingDriverAssignment,
isSavingStatusChange,
assignDriver,
changeDeliveryStatus,
isLoading,
@ -97,16 +100,20 @@ export const GroupDetailPage = () => {
{isLoading ? (
<SkeletonPanel lines={6} />
) : order ? (
<OrderDetailPanel
<ErrorBoundary compact>
<OrderDetailPanel
order={order}
canManageDelivery={["manager", "logistician", "admin", "mega_admin"].includes(userRole)}
onSaveManualDeliveryChoice={saveManualDeliveryChoice}
isSavingDeliveryChoice={isSavingDeliveryChoice}
isSavingDriverAssignment={isSavingDriverAssignment}
isSavingStatusChange={isSavingStatusChange}
drivers={drivers}
onAssignDriver={assignDriver}
onChangeDeliveryStatus={changeDeliveryStatus}
userRole={userRole}
/>
/>
</ErrorBoundary>
) : (
<Panel className="p-6 text-sm text-[var(--color-text-muted)]">
Группа не найдена.