v1.6.5: fix attendance unique constraint, schema sync, seed script

- Change attendance idx_attendance_user_date from unique to index (allow multiple shifts per day)
- Reset migrations to single baseline init migration
- Add seed script with admin user (admin/admin)
- Update CLAUDE.md with migration workflow documentation
- Various frontend fixes (queries, pages, hooks)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-05-17 16:21:34 +02:00
parent 1db5060c42
commit cd6d3daf43
75 changed files with 1748 additions and 1169 deletions

View File

@@ -14,8 +14,14 @@ import { motion, AnimatePresence } from "framer-motion";
import {
offerDetailOptions,
offerCustomersOptions,
offerTemplatesOptions,
scopeTemplatesOptions,
offerNextNumberOptions,
type OfferDetailData,
type OfferItemData,
type OfferSectionData,
type OfferLockInfo,
type OfferOrderInfo,
type ScopeTemplate,
} from "../lib/queries/offers";
import {
@@ -50,7 +56,10 @@ import apiFetch from "../utils/api";
import { formatCurrency } from "../utils/formatters";
import { Skeleton } from "boneyard-js/react";
import OfferDetailFixture from "../fixtures/OfferDetailFixture";
import { companySettingsOptions } from "../lib/queries/settings";
import {
companySettingsOptions,
type CompanySettingsData,
} from "../lib/queries/settings";
const API_BASE = "/api/admin";
const DRAFT_KEY = "boha_offer_draft";
@@ -94,11 +103,6 @@ interface Customer {
city?: string;
}
interface OrderInfo {
id: number;
order_number: string;
}
const emptyForm: OfferForm = {
quotation_number: "",
project_code: "",
@@ -206,7 +210,7 @@ function SortableItemRow({
<input
type="number"
value={item.quantity}
onChange={(e) => onUpdate("quantity", e.target.value)}
onChange={(e) => onUpdate("quantity", parseInt(e.target.value, 10))}
className="admin-form-input"
step="1"
readOnly={readOnly}
@@ -225,7 +229,7 @@ function SortableItemRow({
<input
type="number"
value={item.unit_price}
onChange={(e) => onUpdate("unit_price", e.target.value)}
onChange={(e) => onUpdate("unit_price", parseFloat(e.target.value))}
className="admin-form-input"
step="0.01"
readOnly={readOnly}
@@ -318,34 +322,15 @@ export default function OfferDetail() {
...offerCustomersOptions(),
enabled: !isEdit,
});
const { data: templatesData } = useQuery({
...offerTemplatesOptions(),
enabled: !isEdit,
});
const { data: templatesData } = useQuery(scopeTemplatesOptions());
const { data: nextNumberData } = useQuery({
...offerNextNumberOptions(),
enabled: !isEdit,
});
// Derive typed arrays from query data
const customers = (
Array.isArray(customersData)
? customersData
: (customersData as Record<string, unknown>)?.customers
? ((customersData as Record<string, unknown>).customers as Customer[])
: []
) as Customer[];
const scopeTemplates = (
Array.isArray(templatesData) ? templatesData : []
) as Array<{
id: number;
name: string;
description?: string;
scope_template_sections?: Array<{
title?: string;
title_cz?: string;
content?: string;
}>;
}>;
const customers: Customer[] = Array.isArray(customersData)
? customersData
: [];
const scopeTemplates = templatesData ?? [];
const loading = isEdit && offerQuery.isLoading;
const [saving, setSaving] = useState(false);
@@ -396,7 +381,7 @@ export default function OfferDetail() {
});
const [customerSearch, setCustomerSearch] = useState("");
const [showCustomerDropdown, setShowCustomerDropdown] = useState(false);
const [orderInfo, setOrderInfo] = useState<OrderInfo | null>(null);
const [orderInfo, setOrderInfo] = useState<OfferOrderInfo | null>(null);
const [offerStatus, setOfferStatus] = useState<string>("");
const [deleteConfirm, setDeleteConfirm] = useState(false);
@@ -409,14 +394,7 @@ export default function OfferDetail() {
const [orderAttachment, setOrderAttachment] = useState<File | null>(null);
const [pdfLoading, setPdfLoading] = useState(false);
const blobTimeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([]);
const companySettings = useQuery(companySettingsOptions()).data as unknown as
| {
default_currency: string;
default_vat_rate: number;
available_currencies: string[];
available_vat_rates: number[];
}
| undefined;
const { data: companySettings } = useQuery(companySettingsOptions());
useEffect(() => {
if (companySettings && !isEdit) {
@@ -452,10 +430,13 @@ export default function OfferDetail() {
}, []);
const isInvalidated = offerStatus === "invalidated";
const isCompleted = orderInfo?.status === "dokoncena";
const isLockedByOther = !!lockedBy;
const readOnly = isInvalidated || isLockedByOther || isCompleted;
const isExpiredNotInvalidated =
isEdit &&
!isInvalidated &&
!isCompleted &&
!orderInfo &&
form.valid_until &&
new Date(form.valid_until) < new Date(new Date().toDateString());
@@ -464,28 +445,26 @@ export default function OfferDetail() {
const formInitializedRef = useRef(false);
useEffect(() => {
if (!offerQuery.data || formInitializedRef.current) return;
const d = offerQuery.data as Record<string, unknown>;
const d = offerQuery.data;
const formData: OfferForm = {
quotation_number: (d.quotation_number as string) || "",
project_code: (d.project_code as string) || "",
customer_id: (d.customer_id as number | null) ?? null,
customer_name: (d.customer_name as string) || "",
created_at: d.created_at ? String(d.created_at).substring(0, 10) : "",
valid_until: d.valid_until ? String(d.valid_until).substring(0, 10) : "",
currency:
(d.currency as string) || companySettings?.default_currency || "CZK",
language: (d.language as string) || "EN",
vat_rate:
(d.vat_rate as number) ?? companySettings?.default_vat_rate ?? 21,
quotation_number: d.quotation_number || "",
project_code: d.project_code || "",
customer_id: d.customer_id ?? null,
customer_name: d.customer_name || "",
created_at: d.created_at ? d.created_at.substring(0, 10) : "",
valid_until: d.valid_until ? d.valid_until.substring(0, 10) : "",
currency: d.currency || companySettings?.default_currency || "CZK",
language: d.language || "EN",
vat_rate: d.vat_rate ?? companySettings?.default_vat_rate ?? 21,
apply_vat: !!d.apply_vat,
exchange_rate: (d.exchange_rate as string) || "",
scope_title: (d.scope_title as string) || "",
scope_description: (d.scope_description as string) || "",
exchange_rate: d.exchange_rate || "",
scope_title: d.scope_title || "",
scope_description: d.scope_description || "",
};
setForm(formData);
const mappedItems =
Array.isArray(d.items) && d.items.length
? (d.items as any[]).map((it: any) => ({
? d.items.map((it) => ({
...it,
_key: `item-${++itemKeyCounter.current}`,
}))
@@ -493,7 +472,7 @@ export default function OfferDetail() {
setItems(mappedItems);
const mappedSections =
Array.isArray(d.sections) && d.sections.length
? (d.sections as any[]).map((s: any) => ({
? d.sections.map((s) => ({
title: s.title || "",
title_cz: s.title_cz || "",
content: s.content || "",
@@ -505,14 +484,15 @@ export default function OfferDetail() {
items: mappedItems,
sections: mappedSections,
});
setOfferStatus((d.status as string) || "");
setOrderInfo((d.order as OrderInfo) ?? null);
setLockedBy((d.locked_by as typeof lockedBy) ?? null);
setOfferStatus(d.status || "");
setOrderInfo(d.order ?? null);
setLockedBy(d.locked_by ?? null);
// Try to acquire lock if not locked by someone else and not invalidated
// Try to acquire lock if not locked by someone else, not invalidated, and not completed
if (
!d.locked_by &&
d.status !== "invalidated" &&
d.order?.status !== "dokoncena" &&
hasPermission("offers.edit")
) {
apiFetch(`${API_BASE}/offers/${id}/lock`, { method: "POST" }).catch(
@@ -533,10 +513,7 @@ export default function OfferDetail() {
// Sync next-number data to form (create mode)
useEffect(() => {
if (isEdit || !nextNumberData) return;
const num =
((nextNumberData as Record<string, unknown>).next_number as string) ||
((nextNumberData as Record<string, unknown>).number as string) ||
"";
const num = nextNumberData.next_number || nextNumberData.number || "";
if (num) {
setForm((prev) => ({ ...prev, quotation_number: num }));
}
@@ -544,7 +521,8 @@ export default function OfferDetail() {
// Heartbeat to keep lock alive + cleanup on unmount
useEffect(() => {
if (!isEdit || !id || isLockedByOther || isInvalidated) return;
if (!isEdit || !id || isLockedByOther || isInvalidated || isCompleted)
return;
heartbeatRef.current = setInterval(() => {
apiFetch(`${API_BASE}/offers/${id}/heartbeat`, { method: "POST" }).catch(
@@ -719,6 +697,10 @@ export default function OfferDetail() {
}
}
if (!isEdit && result.data?.id) {
queryClient.invalidateQueries({ queryKey: ["offers"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
queryClient.invalidateQueries({ queryKey: ["invoices"] });
navigate(`/offers/${result.data.id}`);
}
if (isEdit) {
@@ -730,6 +712,7 @@ export default function OfferDetail() {
queryClient.invalidateQueries({ queryKey: ["offers"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
queryClient.invalidateQueries({ queryKey: ["invoices"] });
}
} else {
alert.error(result.error || "Nepodařilo se uložit nabídku");
@@ -772,9 +755,10 @@ export default function OfferDetail() {
if (result.success) {
setShowOrderModal(false);
alert.success(result.message || "Objednávka byla vytvořena");
queryClient.invalidateQueries({ queryKey: ["offers"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
await queryClient.invalidateQueries({ queryKey: ["offers"] });
await queryClient.invalidateQueries({ queryKey: ["orders"] });
await queryClient.invalidateQueries({ queryKey: ["projects"] });
await queryClient.invalidateQueries({ queryKey: ["invoices"] });
navigate(`/orders/${result.data.order_id}`);
} else {
alert.error(result.error || "Nepodařilo se vytvořit objednávku");
@@ -800,6 +784,7 @@ export default function OfferDetail() {
queryClient.invalidateQueries({ queryKey: ["offers"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
queryClient.invalidateQueries({ queryKey: ["invoices"] });
} else {
alert.error(result.error || "Nepodařilo se zneplatnit nabídku");
}
@@ -819,9 +804,10 @@ export default function OfferDetail() {
const result = await response.json();
if (result.success) {
alert.success(result.message || "Nabídka byla smazána");
queryClient.invalidateQueries({ queryKey: ["offers"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
await queryClient.invalidateQueries({ queryKey: ["offers"] });
await queryClient.invalidateQueries({ queryKey: ["orders"] });
await queryClient.invalidateQueries({ queryKey: ["projects"] });
await queryClient.invalidateQueries({ queryKey: ["invoices"] });
navigate("/offers");
} else {
alert.error(result.error || "Nepodařilo se smazat nabídku");
@@ -864,7 +850,7 @@ export default function OfferDetail() {
const getRequiredPerm = () => {
if (!isEdit) return "offers.create";
return isInvalidated ? "offers.view" : "offers.edit";
return isInvalidated || isCompleted ? "offers.view" : "offers.edit";
};
const requiredPerm = getRequiredPerm();
if (!hasPermission(requiredPerm)) return <Forbidden />;
@@ -915,6 +901,17 @@ export default function OfferDetail() {
Zneplatněna
</span>
)}
{isCompleted && (
<span
className="admin-badge admin-badge-success text-xs"
style={{
marginLeft: "0.75rem",
verticalAlign: "middle",
}}
>
Dokončeno
</span>
)}
</h1>
</div>
</div>
@@ -949,7 +946,7 @@ export default function OfferDetail() {
</button>
)}
{isEdit &&
!isInvalidated &&
!readOnly &&
hasPermission("orders.create") &&
!orderInfo && (
<button
@@ -979,7 +976,7 @@ export default function OfferDetail() {
Zneplatnit
</button>
)}
{!isInvalidated && !isLockedByOther && (
{!readOnly && (
<button
onClick={handleSave}
className="admin-btn admin-btn-primary"
@@ -1042,7 +1039,7 @@ export default function OfferDetail() {
{/* Quotation Form */}
<motion.div
className={`admin-editor-section${isInvalidated || isLockedByOther ? " offers-readonly" : ""}`}
className={`admin-editor-section${readOnly ? " offers-readonly" : ""}`}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
@@ -1069,14 +1066,14 @@ export default function OfferDetail() {
onChange={(e) => updateForm("project_code", e.target.value)}
className="admin-form-input"
placeholder="Volitelný kód projektu"
readOnly={isInvalidated || isLockedByOther}
readOnly={readOnly}
/>
</FormField>
<FormField label="Zákazník" error={errors.customer_id} required>
{form.customer_id ? (
<div className="admin-customer-selected">
<span>{form.customer_name}</span>
{!isInvalidated && !isLockedByOther && (
{!readOnly && (
<button
type="button"
onClick={clearCustomer}
@@ -1112,7 +1109,7 @@ export default function OfferDetail() {
onFocus={() => setShowCustomerDropdown(true)}
className="admin-form-input"
placeholder="Hledat zákazníka..."
readOnly={isInvalidated || isLockedByOther}
readOnly={readOnly}
/>
{showCustomerDropdown && !isInvalidated && (
<div className="admin-customer-dropdown">
@@ -1145,7 +1142,7 @@ export default function OfferDetail() {
error={errors.created_at}
required
>
{isInvalidated || isLockedByOther ? (
{readOnly ? (
<input
type="text"
value={form.created_at}
@@ -1168,7 +1165,7 @@ export default function OfferDetail() {
error={errors.valid_until}
required
>
{isInvalidated || isLockedByOther ? (
{readOnly ? (
<input
type="text"
value={form.valid_until}
@@ -1197,7 +1194,7 @@ export default function OfferDetail() {
value={form.currency}
onChange={(e) => updateForm("currency", e.target.value)}
className="admin-form-select"
disabled={isInvalidated || isLockedByOther}
disabled={readOnly}
>
{(
companySettings?.available_currencies || [
@@ -1218,7 +1215,7 @@ export default function OfferDetail() {
value={form.language}
onChange={(e) => updateForm("language", e.target.value)}
className="admin-form-select"
disabled={isInvalidated || isLockedByOther}
disabled={readOnly}
>
<option value="EN">English</option>
<option value="CZ">Čeština</option>
@@ -1235,7 +1232,7 @@ export default function OfferDetail() {
updateForm("vat_rate", parseFloat(e.target.value) || 0)
}
className="admin-form-select flex-1"
disabled={isInvalidated || isLockedByOther}
disabled={readOnly}
>
{(
companySettings?.available_vat_rates || [
@@ -1254,7 +1251,7 @@ export default function OfferDetail() {
onChange={(e) =>
updateForm("apply_vat", e.target.checked)
}
disabled={isInvalidated || isLockedByOther}
disabled={readOnly}
/>
<span>Účtovat DPH</span>
</label>
@@ -1268,7 +1265,7 @@ export default function OfferDetail() {
className="admin-form-input"
placeholder="Volitelný"
step="0.0001"
readOnly={isInvalidated || isLockedByOther}
readOnly={readOnly}
/>
</FormField>
</div>
@@ -1285,7 +1282,7 @@ export default function OfferDetail() {
<div className="admin-card-body">
<div className="admin-card-header flex-between">
<h3 className="admin-card-title">Položky</h3>
{!isInvalidated && !isLockedByOther && (
{!readOnly && (
<button
onClick={addItem}
className="admin-btn admin-btn-secondary admin-btn-sm"
@@ -1327,9 +1324,7 @@ export default function OfferDetail() {
<table className="admin-table">
<thead>
<tr>
{!isInvalidated && !isLockedByOther && (
<th style={{ width: "2rem" }} />
)}
{!readOnly && <th style={{ width: "2rem" }} />}
<th style={{ width: "2.5rem", textAlign: "center" }}>
#
</th>
@@ -1364,7 +1359,7 @@ export default function OfferDetail() {
>
Celkem
</th>
{!isInvalidated && !isLockedByOther && (
{!readOnly && (
<th
className="offers-col-del"
style={{ width: "3rem" }}
@@ -1379,7 +1374,7 @@ export default function OfferDetail() {
item={item}
index={index}
currency={form.currency}
readOnly={isInvalidated || isLockedByOther}
readOnly={readOnly}
canDelete={items.length > 1}
onUpdate={(field, value) =>
updateItem(index, field, value)
@@ -1423,7 +1418,7 @@ export default function OfferDetail() {
<div className="admin-card-body">
<div className="admin-card-header flex-between">
<h3 className="admin-card-title">Rozsah projektu</h3>
{!isInvalidated && !isLockedByOther && (
{!readOnly && (
<div
style={{
display: "flex",
@@ -1444,7 +1439,7 @@ export default function OfferDetail() {
);
if (template?.scope_template_sections?.length) {
const newSections =
template.scope_template_sections.map((s: any) => ({
template.scope_template_sections.map((s) => ({
title: s.title || "",
title_cz: s.title_cz || "",
content: s.content || "",
@@ -1537,7 +1532,7 @@ export default function OfferDetail() {
</span>
)}
</span>
{!isInvalidated && !isLockedByOther && (
{!readOnly && (
<div style={{ display: "flex", gap: "0.25rem" }}>
{idx > 0 && (
<button
@@ -1639,7 +1634,7 @@ export default function OfferDetail() {
}
className="admin-form-input"
placeholder="Název sekce (anglicky)"
readOnly={isInvalidated || isLockedByOther}
readOnly={readOnly}
/>
</FormField>
<FormField
@@ -1666,7 +1661,7 @@ export default function OfferDetail() {
}
className="admin-form-input"
placeholder="Název sekce (česky)"
readOnly={isInvalidated || isLockedByOther}
readOnly={readOnly}
/>
</FormField>
</div>
@@ -1684,7 +1679,7 @@ export default function OfferDetail() {
}
placeholder="Obsah sekce..."
minHeight="120px"
readOnly={isInvalidated || isLockedByOther}
readOnly={readOnly}
/>
</div>
</div>