Compare commits
3
Commits
2a58c5ca94
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3e5d05849 | ||
|
|
8deddf2630 | ||
|
|
771558a551 |
@@ -44,6 +44,22 @@ function RequireAdmin({ children }: { children: React.ReactNode }) {
|
|||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Выдача резины — по складскому праву (warehouse:tyres в /me), не только
|
||||||
|
* админам. Приёмка и инвентаризация остаются под RequireAdmin.
|
||||||
|
*/
|
||||||
|
function RequireTyres({ children }: { children: React.ReactNode }) {
|
||||||
|
const token = useAuth((s) => s.token);
|
||||||
|
const meQuery = useQuery({ queryKey: ["me"], queryFn: getMe, staleTime: 60_000, enabled: !!token });
|
||||||
|
if (!token) return <Navigate to="/login" replace />;
|
||||||
|
if (meQuery.isLoading) return null;
|
||||||
|
const me = meQuery.data;
|
||||||
|
if (!me?.is_admin && !me?.permissions?.includes("warehouse:tyres")) {
|
||||||
|
return <Navigate to="/" replace />;
|
||||||
|
}
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
// У35: фоновая сверка прав механика раз в 5 минут — выкидывает на /login
|
// У35: фоновая сверка прав механика раз в 5 минут — выкидывает на /login
|
||||||
// с сообщением «Доступ отозван», если админ снял can_login_pwa или вывел
|
// с сообщением «Доступ отозван», если админ снял can_login_pwa или вывел
|
||||||
@@ -72,10 +88,10 @@ export default function App() {
|
|||||||
<Route path="/wh/receipts/:id" element={<RequireAdmin><ReceiptDetailPage /></RequireAdmin>} />
|
<Route path="/wh/receipts/:id" element={<RequireAdmin><ReceiptDetailPage /></RequireAdmin>} />
|
||||||
<Route path="/wh/stocktake" element={<RequireAdmin><StocktakePage /></RequireAdmin>} />
|
<Route path="/wh/stocktake" element={<RequireAdmin><StocktakePage /></RequireAdmin>} />
|
||||||
<Route path="/wh/stocktake/:id" element={<RequireAdmin><StocktakeSessionPage /></RequireAdmin>} />
|
<Route path="/wh/stocktake/:id" element={<RequireAdmin><StocktakeSessionPage /></RequireAdmin>} />
|
||||||
{/* Сезонная выдача резины — тот же круг, что приёмка и инвентаризация. */}
|
{/* Сезонная выдача резины — по складскому праву (не только админам). */}
|
||||||
<Route path="/wh/tyres" element={<RequireAdmin><TyresPage /></RequireAdmin>} />
|
<Route path="/wh/tyres" element={<RequireTyres><TyresPage /></RequireTyres>} />
|
||||||
<Route path="/wh/tyres/sizes" element={<RequireAdmin><TyreSizesPage /></RequireAdmin>} />
|
<Route path="/wh/tyres/sizes" element={<RequireTyres><TyreSizesPage /></RequireTyres>} />
|
||||||
<Route path="/wh/tyres/journal" element={<RequireAdmin><TyreJournalPage /></RequireAdmin>} />
|
<Route path="/wh/tyres/journal" element={<RequireTyres><TyreJournalPage /></RequireTyres>} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -379,6 +379,18 @@ export interface TyreStockRow {
|
|||||||
fits: boolean;
|
fits: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Докладка в выданный комплект — ещё колесо на ту же машину. */
|
||||||
|
export interface TyreAddition {
|
||||||
|
id: number;
|
||||||
|
part_id: number;
|
||||||
|
part_name: string | null;
|
||||||
|
qty: number;
|
||||||
|
grade: TyreGrade | null;
|
||||||
|
created_at: string;
|
||||||
|
created_by: string | null;
|
||||||
|
comment: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TyreSet {
|
export interface TyreSet {
|
||||||
id: number;
|
id: number;
|
||||||
car_id: number | null;
|
car_id: number | null;
|
||||||
@@ -400,6 +412,8 @@ export interface TyreSet {
|
|||||||
grade_in: TyreGrade | null;
|
grade_in: TyreGrade | null;
|
||||||
close_comment: string | null;
|
close_comment: string | null;
|
||||||
comment: string | null;
|
comment: string | null;
|
||||||
|
/** Докладки. `qty` выше их уже включает: это все колёса на машине. */
|
||||||
|
additions?: TyreAddition[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TyreContext {
|
export interface TyreContext {
|
||||||
@@ -437,6 +451,8 @@ export async function issueTyres(
|
|||||||
place_id?: number | null;
|
place_id?: number | null;
|
||||||
comment?: string | null;
|
comment?: string | null;
|
||||||
close?: TyreClose | null;
|
close?: TyreClose | null;
|
||||||
|
/** Доложить в открытый комплект вместо выдачи нового (тогда close не нужен). */
|
||||||
|
add_to_open?: boolean;
|
||||||
},
|
},
|
||||||
idempotencyKey: string,
|
idempotencyKey: string,
|
||||||
): Promise<TyreSet> {
|
): Promise<TyreSet> {
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export default function ActionMenu() {
|
|||||||
emoji: "🔧",
|
emoji: "🔧",
|
||||||
onClick: () => navigate("/repairs"),
|
onClick: () => navigate("/repairs"),
|
||||||
},
|
},
|
||||||
// Складские разделы правят остатки и деньги — пока только админам
|
// Приёмка и инвентаризация правят остатки и деньги — пока только админам
|
||||||
// (тот же гейт стоит на сервере, здесь просто не показываем лишнего).
|
// (тот же гейт стоит на сервере, здесь просто не показываем лишнего).
|
||||||
...(meQuery.data?.is_admin
|
...(meQuery.data?.is_admin
|
||||||
? [
|
? [
|
||||||
@@ -52,6 +52,12 @@ export default function ActionMenu() {
|
|||||||
emoji: "📋",
|
emoji: "📋",
|
||||||
onClick: () => navigate("/wh/stocktake"),
|
onClick: () => navigate("/wh/stocktake"),
|
||||||
},
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
// Выдача резины — по складскому праву (не только админам): признак
|
||||||
|
// warehouse:tyres приходит в /me, если у механика есть право склада.
|
||||||
|
...(meQuery.data?.is_admin || meQuery.data?.permissions?.includes("warehouse:tyres")
|
||||||
|
? [
|
||||||
{
|
{
|
||||||
key: "tyres",
|
key: "tyres",
|
||||||
title: "Выдача резины",
|
title: "Выдача резины",
|
||||||
|
|||||||
@@ -51,6 +51,15 @@ function daysOn(iso: string): number {
|
|||||||
return Number.isNaN(d) ? 0 : Math.max(0, Math.round((Date.now() - d) / 86_400_000));
|
return Number.isNaN(d) ? 0 : Math.max(0, Math.round((Date.now() - d) / 86_400_000));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Подпись кнопки выдачи. «Комплект» — только когда колёс действительно четыре:
|
||||||
|
* с тех пор как выдавать можно поштучно, на одной покрышке эта подпись врала. */
|
||||||
|
function issueLabel(qty: number, addToOpen: boolean): string {
|
||||||
|
const noun = qty === 1 ? "покрышку" : "покрышки";
|
||||||
|
if (addToOpen) return `Доложить ${qty} ${noun}`;
|
||||||
|
if (qty === 4) return "Выдать комплект";
|
||||||
|
return `Выдать ${qty} ${noun}`;
|
||||||
|
}
|
||||||
|
|
||||||
export default function TyresPage() {
|
export default function TyresPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [sp, setSp] = useSearchParams();
|
const [sp, setSp] = useSearchParams();
|
||||||
@@ -172,6 +181,7 @@ function IssueForm({ carId, onDone }: { carId: number; onDone: () => void }) {
|
|||||||
const [comment, setComment] = useState("");
|
const [comment, setComment] = useState("");
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [showAll, setShowAll] = useState(false);
|
const [showAll, setShowAll] = useState(false);
|
||||||
|
const [addToOpen, setAddToOpen] = useState(false);
|
||||||
// Ключ идемпотентности живёт, пока форма открыта: ретрай после обрыва связи
|
// Ключ идемпотентности живёт, пока форма открыта: ретрай после обрыва связи
|
||||||
// должен попасть в ТУ ЖЕ выдачу, а не создать вторую.
|
// должен попасть в ТУ ЖЕ выдачу, а не создать вторую.
|
||||||
const [idemKey] = useState(() => crypto.randomUUID());
|
const [idemKey] = useState(() => crypto.randomUUID());
|
||||||
@@ -201,7 +211,10 @@ function IssueForm({ carId, onDone }: { carId: number; onDone: () => void }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const openSet = ctx.open_set;
|
const openSet = ctx.open_set;
|
||||||
const needClose = openSet !== null && closeAction === null;
|
// «Доложить» — когда старые колёса остаются на машине, а добавляется ещё
|
||||||
|
// одно-два. Закрывать комплект ради этого нельзя: «списали» врёт на живой
|
||||||
|
// резине, «вернули» приходует на склад то, что с машины не снимали.
|
||||||
|
const needClose = openSet !== null && !addToOpen && closeAction === null;
|
||||||
const chosen = ctx.stock.find((s) => s.part_id === partId) ?? null;
|
const chosen = ctx.stock.find((s) => s.part_id === partId) ?? null;
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
@@ -218,7 +231,9 @@ function IssueForm({ carId, onDone }: { carId: number; onDone: () => void }) {
|
|||||||
qty,
|
qty,
|
||||||
grade,
|
grade,
|
||||||
comment: comment.trim() || null,
|
comment: comment.trim() || null,
|
||||||
close: closeAction
|
add_to_open: addToOpen,
|
||||||
|
close:
|
||||||
|
!addToOpen && closeAction
|
||||||
? {
|
? {
|
||||||
action: closeAction,
|
action: closeAction,
|
||||||
grade: closeAction === "returned" ? closeGrade : undefined,
|
grade: closeAction === "returned" ? closeGrade : undefined,
|
||||||
@@ -227,10 +242,12 @@ function IssueForm({ carId, onDone }: { carId: number; onDone: () => void }) {
|
|||||||
},
|
},
|
||||||
idemKey,
|
idemKey,
|
||||||
);
|
);
|
||||||
toast.success(`Выдано ${qty} шт · ${ctx.car_number ?? ""}`.trim());
|
toast.success(
|
||||||
|
`${addToOpen ? "Доложено" : "Выдано"} ${qty} шт · ${ctx.car_number ?? ""}`.trim(),
|
||||||
|
);
|
||||||
onDone();
|
onDone();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(await apiErrorText(e, "Не удалось выдать комплект"));
|
toast.error(await apiErrorText(e, "Не удалось выдать резину"));
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -268,7 +285,40 @@ function IssueForm({ carId, onDone }: { carId: number; onDone: () => void }) {
|
|||||||
{` (${daysOn(openSet.issued_at)} дн.)`}
|
{` (${daysOn(openSet.issued_at)} дн.)`}
|
||||||
{openSet.grade_out ? ` · при выдаче ${TYRE_GRADE_LABEL[openSet.grade_out].toLowerCase()}` : ""}
|
{openSet.grade_out ? ` · при выдаче ${TYRE_GRADE_LABEL[openSet.grade_out].toLowerCase()}` : ""}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm mt-2 mb-1">Что с ним?</p>
|
{openSet.additions && openSet.additions.length > 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
из них доложено:{" "}
|
||||||
|
{openSet.additions
|
||||||
|
.map((a) => `${a.qty} шт ${fmtDate(a.created_at)}`)
|
||||||
|
.join(", ")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Развилка: доложить к тем колёсам или заменить их целиком. */}
|
||||||
|
<div className="grid grid-cols-2 gap-2 mt-2">
|
||||||
|
<Button
|
||||||
|
variant={addToOpen ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setAddToOpen(true)}
|
||||||
|
>
|
||||||
|
Доложить
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={!addToOpen ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setAddToOpen(false)}
|
||||||
|
>
|
||||||
|
Заменить
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{addToOpen ? (
|
||||||
|
<p className="text-xs text-muted-foreground mt-2">
|
||||||
|
Комплект остаётся тем же, колёса добавятся к нему. Размер должен совпадать.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="text-sm mt-3 mb-1">Что со старым?</p>
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<div className="grid grid-cols-3 gap-2">
|
||||||
{(Object.keys(CLOSE_LABEL) as TyreClose["action"][]).map((a) => (
|
{(Object.keys(CLOSE_LABEL) as TyreClose["action"][]).map((a) => (
|
||||||
<Button
|
<Button
|
||||||
@@ -281,7 +331,9 @@ function IssueForm({ carId, onDone }: { carId: number; onDone: () => void }) {
|
|||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{closeAction === "returned" && (
|
</>
|
||||||
|
)}
|
||||||
|
{!addToOpen && closeAction === "returned" && (
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<p className="text-sm mb-1">Состояние снятой резины</p>
|
<p className="text-sm mb-1">Состояние снятой резины</p>
|
||||||
<GradePicker value={closeGrade} onChange={setCloseGrade} />
|
<GradePicker value={closeGrade} onChange={setCloseGrade} />
|
||||||
@@ -344,8 +396,11 @@ function IssueForm({ carId, onDone }: { carId: number; onDone: () => void }) {
|
|||||||
{/* Количество и состояние */}
|
{/* Количество и состояние */}
|
||||||
<section className="p-3 border-b">
|
<section className="p-3 border-b">
|
||||||
<p className="text-sm mb-1">Сколько покрышек</p>
|
<p className="text-sm mb-1">Сколько покрышек</p>
|
||||||
|
{/* Поштучно, а не только комплектом: в переобувку часто уходит одно-два
|
||||||
|
колеса — порезали боковину, поймали грыжу. Раньше выбор был 2/4/5 и
|
||||||
|
под такой случай не подходил (задача 4684). */}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{[2, 4, 5].map((n) => (
|
{[1, 2, 3, 4].map((n) => (
|
||||||
<Button
|
<Button
|
||||||
key={n}
|
key={n}
|
||||||
variant={qty === n ? "default" : "outline"}
|
variant={qty === n ? "default" : "outline"}
|
||||||
@@ -370,7 +425,11 @@ function IssueForm({ carId, onDone }: { carId: number; onDone: () => void }) {
|
|||||||
{/* Кнопка выдачи */}
|
{/* Кнопка выдачи */}
|
||||||
<div className="fixed bottom-0 left-0 right-0 border-t bg-background p-3">
|
<div className="fixed bottom-0 left-0 right-0 border-t bg-background p-3">
|
||||||
<Button className="w-full" disabled={saving || needClose || partId === null} onClick={submit}>
|
<Button className="w-full" disabled={saving || needClose || partId === null} onClick={submit}>
|
||||||
{saving ? "Выдаём…" : needClose ? "Сначала закройте предыдущий комплект" : "Выдать комплект"}
|
{saving
|
||||||
|
? "Выдаём…"
|
||||||
|
: needClose
|
||||||
|
? "Сначала выберите: доложить или заменить"
|
||||||
|
: issueLabel(qty, addToOpen)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
Reference in New Issue
Block a user