From 90a27b893ef07435a34185416cee5710c309604e Mon Sep 17 00:00:00 2001 From: BOHA Date: Sun, 7 Jun 2026 08:21:31 +0200 Subject: [PATCH] feat(mui): migrate Offer Detail editor onto MUI kit Co-Authored-By: Claude Opus 4.8 (1M context) --- src/admin/pages/OfferDetail.tsx | 1414 +++++++++++++++++-------------- 1 file changed, 761 insertions(+), 653 deletions(-) diff --git a/src/admin/pages/OfferDetail.tsx b/src/admin/pages/OfferDetail.tsx index b0ec34a..859ed20 100644 --- a/src/admin/pages/OfferDetail.tsx +++ b/src/admin/pages/OfferDetail.tsx @@ -9,8 +9,19 @@ import { import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useAlert } from "../context/AlertContext"; import { useAuth } from "../context/AuthContext"; -import { useParams, useNavigate, Link } from "react-router-dom"; -import { motion, AnimatePresence } from "framer-motion"; +import { useParams, useNavigate, Link as RouterLink } from "react-router-dom"; +import { motion } from "framer-motion"; +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import IconButton from "@mui/material/IconButton"; +import CircularProgress from "@mui/material/CircularProgress"; +import Table from "@mui/material/Table"; +import TableBody from "@mui/material/TableBody"; +import TableCell from "@mui/material/TableCell"; +import TableContainer from "@mui/material/TableContainer"; +import TableHead from "@mui/material/TableHead"; +import TableRow from "@mui/material/TableRow"; +import Checkbox from "@mui/material/Checkbox"; import { offerDetailOptions, offerCustomersOptions, @@ -18,12 +29,7 @@ import { offerNextNumberOptions, itemTemplatesOptions, type ItemTemplate, - type OfferDetailData, - type OfferItemData, - type OfferSectionData, - type OfferLockInfo, type OfferOrderInfo, - type ScopeTemplate, type Customer, } from "../lib/queries/offers"; @@ -48,20 +54,25 @@ import { restrictToParentElement, } from "@dnd-kit/modifiers"; import { CSS } from "@dnd-kit/utilities"; -import ConfirmModal from "../components/ConfirmModal"; -import FormModal from "../components/FormModal"; -import FormField from "../components/FormField"; import Forbidden from "../components/Forbidden"; -import AdminDatePicker from "../components/AdminDatePicker"; import RichEditor from "../components/RichEditor"; import useDebounce from "../hooks/useDebounce"; import apiFetch from "../utils/api"; import { formatCurrency, todayLocalStr } from "../utils/formatters"; +import { companySettingsOptions } from "../lib/queries/settings"; import { - companySettingsOptions, - type CompanySettingsData, -} from "../lib/queries/settings"; -import { useApiMutation } from "../lib/queries/mutations"; + Button, + Card, + TextField, + Select, + DateField, + Field, + StatusChip, + Modal, + ConfirmDialog, + EmptyState, + LoadingState, +} from "../ui"; const API_BASE = "/api/admin"; const DRAFT_KEY = "boha_offer_draft"; @@ -115,6 +126,84 @@ const emptyScopeSection = (): ScopeSection => ({ content: "", }); +const BackIcon = ( + + + +); + +const FileIcon = ( + + + + +); + +const DragIcon = ( + + + + + + + + +); + +const RemoveIcon = ( + + + + +); + +const UpIcon = ( + + + +); + +const DownIcon = ( + + + +); + function SortableItemRow({ item, index, @@ -144,121 +233,103 @@ function SortableItemRow({ transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1, - background: isDragging ? "var(--bg-secondary)" : undefined, + background: isDragging ? "var(--mui-palette-action-hover)" : undefined, position: "relative" as const, zIndex: isDragging ? 10 : undefined, }; const lineTotal = (Number(item.quantity) || 0) * (Number(item.unit_price) || 0); return ( - + {!readOnly && ( - - - + {DragIcon} + + )} - {index + 1} - -
- + {index + 1} + + + + onUpdate("description", e.target.value)} - className="admin-form-input fw-500" placeholder="Název položky" - readOnly={readOnly} + InputProps={{ readOnly }} + sx={{ "& input": { fontWeight: 500 } }} /> - onUpdate("item_description", e.target.value)} - className="admin-form-input" placeholder="Podrobný popis (volitelný)" - readOnly={readOnly} - style={{ fontSize: "0.8rem", opacity: 0.8 }} + InputProps={{ readOnly }} + sx={{ "& input": { fontSize: "0.8rem", opacity: 0.8 } }} /> -
- - - + + + onUpdate("quantity", parseInt(e.target.value, 10))} - className="admin-form-input" - step="1" - readOnly={readOnly} + slotProps={{ htmlInput: { step: "1" } }} + InputProps={{ readOnly }} /> - - - + + onUpdate("unit", e.target.value)} - className="admin-form-input" - readOnly={readOnly} + InputProps={{ readOnly }} /> - - - + + onUpdate("unit_price", parseFloat(e.target.value))} - className="admin-form-input" - step="0.01" - readOnly={readOnly} + slotProps={{ htmlInput: { step: "0.01" } }} + InputProps={{ readOnly }} /> - - - + + onUpdate("is_included_in_total", e.target.checked)} disabled={readOnly} + size="small" /> - - + + {formatCurrency(lineTotal, currency)} - + {!readOnly && ( - - - + {RemoveIcon} + + )} - +
); } @@ -410,7 +481,7 @@ export default function OfferDetail() { // expected here, not a real error). const deletingRef = useRef(false); - // Note: FormModal applies useModalLock internally. + // Note: Modal applies useDialogScrollLock internally. useEffect(() => { return () => { blobTimeoutsRef.current.forEach(clearTimeout); @@ -841,169 +912,145 @@ export default function OfferDetail() { if (!hasPermission(requiredPerm)) return ; if (loading) { - return ( -
-
-
- ); + return ; } return ( -
+ {/* Header */} -
- - + + - -
-

- {isEdit ? `Nabídka ${form.quotation_number}` : "Nová nabídka"} - {isInvalidated && ( - - Zneplatněna - - )} - {isCompleted && ( - - Dokončeno - - )} -

-
-
-
- {isEdit && hasPermission("offers.export") && ( - - )} - {isEdit && - !readOnly && - hasPermission("orders.create") && - !orderInfo && ( - + Zobrazit nabídku + )} - {isEdit && orderInfo && ( - - Objednávka {orderInfo.order_number} - - )} - {canInvalidate && hasPermission("offers.edit") && ( - - )} - {!readOnly && ( - )} - - )} - {isEdit && hasPermission("offers.delete") && ( - - )} -
+ {isEdit && orderInfo && ( + + )} + {canInvalidate && hasPermission("offers.edit") && ( + + )} + {!readOnly && ( + + )} + {isEdit && hasPermission("offers.delete") && ( + + )} +
+ {/* Lock banner */} {isLockedByOther && ( -
{lockedBy!.full_name}. Můžete ji pouze prohlížet. -
+ )} {/* Quotation Form */} -

Základní údaje

-
-
- - + + Základní údaje + + + + - - - + + updateForm("project_code", e.target.value)} - className="admin-form-input" placeholder="Volitelný kód projektu" - readOnly={readOnly} + InputProps={{ readOnly }} /> - - + + {form.customer_id ? ( -
- {form.customer_name} + + + {form.customer_name} + {!readOnly && ( - + )} -
+ ) : ( -
e.stopPropagation()} > - { setCustomerSearch(e.target.value); setShowCustomerDropdown(true); }} onFocus={() => setShowCustomerDropdown(true)} - className="admin-form-input" placeholder="Hledat zákazníka..." - readOnly={readOnly} + InputProps={{ readOnly }} /> {showCustomerDropdown && !isInvalidated && ( -
+ {filteredCustomers.length === 0 ? ( -
+ Žádní zákazníci -
+
) : ( filteredCustomers.slice(0, 20).map((c) => ( -
selectCustomer(c)} + sx={{ + px: 1.5, + py: 1, + cursor: "pointer", + "&:hover": { bgcolor: "action.hover" }, + }} > -
{c.name}
- {c.city &&
{c.city}
} -
+ {c.name} + {c.city && ( + + {c.city} + + )} + )) )} -
+ )} -
+ )} -
-
+ + -
- + + {readOnly ? ( - ) : ( - { + onChange={(val) => { updateForm("created_at", val); setErrors((prev) => ({ ...prev, created_at: undefined })); }} /> )} - - + + {readOnly ? ( - ) : ( - { + onChange={(val) => { updateForm("valid_until", val); setErrors((prev) => ({ ...prev, @@ -1168,103 +1252,112 @@ export default function OfferDetail() { }} /> )} - -
+ + -
- - updateForm("currency", e.target.value)} - className="admin-form-select" + onChange={(val) => updateForm("currency", val)} disabled={readOnly} - > - {( + options={( companySettings?.available_currencies || [ "CZK", "EUR", "USD", "GBP", ] - ).map((c) => ( - - ))} - - - - updateForm("language", e.target.value)} - className="admin-form-select" + onChange={(val) => updateForm("language", val)} disabled={readOnly} - > - - - - -
+ options={[ + { value: "EN", label: "English" }, + { value: "CZ", label: "Čeština" }, + ]} + /> + + -
- -
- + updateForm("vat_rate", parseFloat(val) || 0) } - className="admin-form-select flex-1" disabled={readOnly} - > - {( + sx={{ flex: 1 }} + options={( companySettings?.available_vat_rates || [0, 10, 12, 15, 21] - ).map((r) => ( - - ))} - - -
-
-
-
+ Účtovat DPH + + + + +
{/* Items Section with drag-and-drop */} -
-
-

Položky

+ + + + Položky + {!readOnly && ( -
+ {itemTemplates && itemTemplates.length > 0 && ( - { + const templateId = Number(val); if (!templateId) return; const template = itemTemplates.find( (t: ItemTemplate) => t.id === templateId, @@ -1286,33 +1379,39 @@ export default function OfferDetail() { `Načtena šablona položky "${template.name}"`, ); } - e.target.value = ""; }} - > - - {itemTemplates.map((t: ItemTemplate) => ( - - ))} - + sx={{ minWidth: 160 }} + options={[ + { value: "", label: "Ze šablony..." }, + ...itemTemplates.map((t: ItemTemplate) => ({ + value: String(t.id), + label: t.name, + })), + ]} + /> )} - -
+ +
)} -
+ {errors.items && ( -

+ {errors.items} -

+ )} -
+ i._key)} strategy={verticalListSortingStrategy} > - - - - {!readOnly &&
} - + + + + {!readOnly && } + # - - - - - - - - {!readOnly && ( - - - + + {!readOnly && } + + + {items.map((item, index) => ( removeItem(index)} /> ))} - -
Popis - Množství - - Jednotka - - Cena/ks - + + Popis + Množství + Jednotka + Cena/ks + V ceně - + + Celkem - - )} -
+ +
-
+ {/* Totals */} -
-
- Mezisoučet: - {formatCurrency(subtotal, form.currency)} -
+ + + + Mezisoučet: + + + {formatCurrency(subtotal, form.currency)} + + {form.apply_vat && ( -
- DPH ({form.vat_rate}%): - {formatCurrency(vatAmount, form.currency)} -
+ + + DPH ({form.vat_rate}%): + + + {formatCurrency(vatAmount, form.currency)} + + )} -
- Celkem: - {formatCurrency(total, form.currency)} -
-
-
+ + + Celkem: + + + {formatCurrency(total, form.currency)} + + + +
{/* Scope/Range Section */} -
-
-

Rozsah projektu

+ + + + Rozsah projektu + {!readOnly && ( -
+ {scopeTemplates.length > 0 && ( - { + const templateId = Number(val); if (!templateId) return; const template = scopeTemplates.find( (t) => t.id === templateId, @@ -1456,86 +1583,82 @@ export default function OfferDetail() { setSections((prev) => [...prev, ...newSections]); alert.success(`Načtena šablona "${template.name}"`); } - e.target.value = ""; }} - > - - {scopeTemplates.map((t) => ( - - ))} - + sx={{ minWidth: 160 }} + options={[ + { value: "", label: "Ze šablony..." }, + ...scopeTemplates.map((t) => ({ + value: String(t.id), + label: t.name, + })), + ]} + /> )} - -
+ +
)} -
+ {sections.length === 0 ? ( -
-

- Žádné sekce rozsahu. Klikněte na "Přidat sekci" nebo vyberte - šablonu. -

-
+ ) : ( -
{sections.map((section, idx) => ( -
-
- Sekce {idx + 1} {(form.language === "CZ" ? section.title_cz : section.title) && ( - + —{" "} {form.language === "CZ" ? section.title_cz || section.title : section.title} - + )} - + {!readOnly && ( -
+ {idx > 0 && ( - + {UpIcon} + )} {idx < sections.length - 1 && ( - + {DownIcon} + )} - -
+ {RemoveIcon} + + )} -
+ -
- - ENNázev - sekce - - } - > - + + + + + Název sekce + + + setSections((prev) => @@ -1632,23 +1745,29 @@ export default function OfferDetail() { ), ) } - className="admin-form-input" placeholder="Název sekce (anglicky)" - readOnly={readOnly} + InputProps={{ readOnly }} /> - - - - CZ - + + + + + Název sekce - - } - > - + + setSections((prev) => @@ -1659,15 +1778,13 @@ export default function OfferDetail() { ), ) } - className="admin-form-input" placeholder="Název sekce (česky)" - readOnly={readOnly} + InputProps={{ readOnly }} /> - -
+ + -
- + @@ -1681,96 +1798,87 @@ export default function OfferDetail() { minHeight="120px" readOnly={readOnly} /> -
-
+ + ))} -
+ )} -
+
{/* Order modal */} - !creatingOrder && setShowOrderModal(false)} onSubmit={handleCreateOrder} title="Vytvořit objednávku" - submitLabel={creatingOrder ? "Vytváření..." : "Vytvořit"} + submitText={creatingOrder ? "Vytváření..." : "Vytvořit"} loading={creatingOrder} + maxWidth="sm" > -
- - ) => - setCustomerOrderNumber(e.target.value) - } - onKeyDown={(e) => - e.key === "Enter" && !creatingOrder && handleCreateOrder() - } - className="admin-form-input" - placeholder="Např. PO-2026-001" - autoFocus - /> - - - {orderAttachment ? ( -
- - {orderAttachment.name}{" "} - - ({(orderAttachment.size / 1024).toFixed(0)} KB) - - - -
- ) : ( - - )} - - Max 10 MB - -
-
-
+ + + + + + ) : ( + + )} + + - setInvalidateConfirm(false)} onConfirm={handleInvalidateOffer} @@ -1778,11 +1886,11 @@ export default function OfferDetail() { message={`Opravdu chcete zneplatnit nabídku "${form.quotation_number}"? Nabídka bude pouze pro čtení a nepůjde upravovat.`} confirmText="Zneplatnit" cancelText="Zrušit" - type="danger" + confirmVariant="danger" loading={invalidatingOffer} /> - setDeleteConfirm(false)} onConfirm={handleDelete} @@ -1790,9 +1898,9 @@ export default function OfferDetail() { message={`Opravdu chcete smazat nabídku "${form.quotation_number}"? Budou smazány i všechny položky a sekce. Tato akce je nevratná.`} confirmText="Smazat" cancelText="Zrušit" - type="danger" + confirmVariant="danger" loading={deleting} /> -
+ ); }