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:
parent
129175fed7
commit
2f2dae686c
|
|
@ -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
|
||||
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
|
||||
</h2>
|
||||
<p style={{ marginBottom: '1rem', color: '#718096', fontSize: '0.9rem' }}>
|
||||
An unexpected error occurred. You can try again.
|
||||
</p>
|
||||
<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}
|
||||
style={{
|
||||
padding: '0.5rem 1.25rem',
|
||||
fontSize: '0.9rem',
|
||||
fontWeight: 600,
|
||||
color: '#fff',
|
||||
backgroundColor: '#3182ce',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
className="mt-2 rounded-xl bg-[var(--color-primary)] px-4 py-2 text-sm font-semibold text-white"
|
||||
>
|
||||
Try Again
|
||||
Попробовать снова
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<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 className="mb-6 max-w-md text-sm text-[var(--color-text-muted)]">
|
||||
Произошла непредвиденная ошибка. Попробуйте обновить страницу или вернуться позже.
|
||||
</p>
|
||||
{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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
<ErrorBoundary compact>
|
||||
<OrderDetailPanel order={deliverySet} />
|
||||
</ErrorBoundary>
|
||||
|
||||
{onClose ? (
|
||||
<div className="flex justify-end">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
@ -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 ? (
|
||||
<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)]">
|
||||
Группа не найдена.
|
||||
|
|
|
|||
Loading…
Reference in New Issue