3 Commits

Author SHA1 Message Date
BOHA
3bd0d055d9 v1.6.0: fix offer items mobile layout and localStorage draft save/restore
- Fix items table description column width on mobile (was ~82px, now ~260px)
- Activate unused offers-items-table CSS class in OfferDetail form
- Save all form fields to localStorage draft (language, VAT, exchange rate were missing)
- Use DRAFT_KEY constant in loadOfferDraft, add error logging

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 15:23:42 +02:00
BOHA
746d17e182 fix: parse YYYY-MM month filter correctly in attendance history
The frontend sends month as "YYYY-MM" but the route handler was passing
it through Number() which parsed only the year portion, causing the
service to ignore the month filter entirely.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 09:29:47 +02:00
BOHA
e96e51598a v1.5.8: fix audit log table layout (Skeleton outside tbody)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 09:08:15 +02:00
5 changed files with 230 additions and 109 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "app-ts", "name": "app-ts",
"version": "1.5.7", "version": "1.6.0",
"description": "", "description": "",
"main": "dist/server.js", "main": "dist/server.js",
"scripts": { "scripts": {

View File

@@ -491,10 +491,69 @@
display: none; display: none;
} }
@media (max-width: 768px) {
.offers-items-table .admin-table td .admin-form-input {
font-size: 16px;
min-height: 44px;
padding: 9px 10px;
}
/* Give the description column more room by shrinking numeric columns */
.offers-col-qty {
width: 4rem !important;
}
.offers-col-unit {
width: 4rem !important;
}
.offers-col-price {
width: 5.5rem !important;
}
.offers-col-included {
width: 3.5rem !important;
}
.offers-col-total {
width: 5.5rem !important;
}
.offers-col-del {
width: 2.5rem !important;
}
}
@media (max-width: 640px) { @media (max-width: 640px) {
.offers-items-table { .offers-items-table {
margin: 0 -1rem; margin: 0 -1rem;
width: calc(100% + 2rem); width: calc(100% + 2rem);
border-radius: 0;
border-left: none;
border-right: none;
}
.offers-items-table .admin-table {
min-width: 700px;
}
.offers-items-table .admin-table td {
padding: 6px;
font-size: 12px;
}
.offers-items-table .admin-table th {
font-size: 10px;
padding: 6px;
}
/* Further reduce numeric columns on very small screens */
.offers-col-qty {
width: 3.5rem !important;
}
.offers-col-unit {
width: 3.5rem !important;
}
.offers-col-price {
width: 5rem !important;
}
.offers-col-total {
width: 5rem !important;
} }
} }

View File

@@ -391,32 +391,25 @@ export default function AuditLog() {
> >
<div className="admin-card-body"> <div className="admin-card-body">
<div className="admin-table-responsive"> <div className="admin-table-responsive">
<table className="admin-table"> <Skeleton
<thead> name="audit-log-rows"
<tr> loading={isPending}
<th>Čas</th> fixture={
<th>Uživatel</th> <table className="admin-table">
<th>Akce</th> <thead>
<th>Typ entity</th> <tr>
<th>Popis</th> <th>Čas</th>
<th>IP</th> <th>Uživatel</th>
</tr> <th>Akce</th>
</thead> <th>Typ entity</th>
<tbody> <th>Popis</th>
<Skeleton <th>IP</th>
name="audit-log-rows" </tr>
loading={isPending} </thead>
fixture={ <tbody>
<div style={{ padding: "1rem" }}> {Array.from({ length: 10 }, (_, i) => (
{Array.from({ length: 10 }, (_, i) => ( <tr key={i}>
<div <td>
key={i}
style={{
display: "flex",
gap: "1rem",
marginBottom: "0.75rem",
}}
>
<div <div
style={{ style={{
width: 110, width: 110,
@@ -425,6 +418,8 @@ export default function AuditLog() {
borderRadius: 4, borderRadius: 4,
}} }}
/> />
</td>
<td>
<div <div
style={{ style={{
width: 80, width: 80,
@@ -433,6 +428,8 @@ export default function AuditLog() {
borderRadius: 4, borderRadius: 4,
}} }}
/> />
</td>
<td>
<div <div
style={{ style={{
width: 70, width: 70,
@@ -441,6 +438,8 @@ export default function AuditLog() {
borderRadius: 10, borderRadius: 10,
}} }}
/> />
</td>
<td>
<div <div
style={{ style={{
width: 80, width: 80,
@@ -449,14 +448,18 @@ export default function AuditLog() {
borderRadius: 4, borderRadius: 4,
}} }}
/> />
</td>
<td>
<div <div
style={{ style={{
flex: 1, width: "100%",
height: 14, height: 14,
background: "var(--bg-tertiary)", background: "var(--bg-tertiary)",
borderRadius: 4, borderRadius: 4,
}} }}
/> />
</td>
<td>
<div <div
style={{ style={{
width: 90, width: 90,
@@ -465,63 +468,75 @@ export default function AuditLog() {
borderRadius: 4, borderRadius: 4,
}} }}
/> />
</div>
))}
</div>
}
>
<>
{logs.length === 0 && (
<tr>
<td colSpan={6}>
<div className="admin-empty-state">
<div className="admin-empty-icon">
<svg
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
</svg>
</div>
<p>Žádné záznamy k zobrazení</p>
</div>
</td> </td>
</tr> </tr>
)} ))}
{logs.length > 0 && </tbody>
logs.map((log) => ( </table>
<tr key={log.id}> }
<td className="admin-mono"> >
{formatDatetime(log.created_at)} <table className="admin-table">
</td> <thead>
<td className="fw-500">{log.username || "-"}</td> <tr>
<td> <th>Čas</th>
<span <th>Uživatel</th>
className={`admin-badge ${ACTION_BADGE_CLASS[log.action] || "admin-badge-secondary"}`} <th>Akce</th>
<th>Typ entity</th>
<th>Popis</th>
<th>IP</th>
</tr>
</thead>
<tbody>
{logs.length === 0 && (
<tr>
<td colSpan={6}>
<div className="admin-empty-state">
<div className="admin-empty-icon">
<svg
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
> >
{ACTION_LABELS[log.action] || log.action} <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
</span> <polyline points="14 2 14 8 20 8" />
</td> <line x1="16" y1="13" x2="8" y2="13" />
<td> <line x1="16" y1="17" x2="8" y2="17" />
{ENTITY_TYPE_LABELS[log.entity_type || ""] || </svg>
log.entity_type || </div>
"-"} <p>Žádné záznamy k zobrazení</p>
</td> </div>
<td>{log.description || "-"}</td> </td>
<td className="admin-mono">{log.user_ip || "-"}</td> </tr>
</tr> )}
))} {logs.length > 0 &&
</> logs.map((log) => (
</Skeleton> <tr key={log.id}>
</tbody> <td className="admin-mono">
</table> {formatDatetime(log.created_at)}
</td>
<td className="fw-500">{log.username || "-"}</td>
<td>
<span
className={`admin-badge ${ACTION_BADGE_CLASS[log.action] || "admin-badge-secondary"}`}
>
{ACTION_LABELS[log.action] || log.action}
</span>
</td>
<td>
{ENTITY_TYPE_LABELS[log.entity_type || ""] ||
log.entity_type ||
"-"}
</td>
<td>{log.description || "-"}</td>
<td className="admin-mono">{log.user_ip || "-"}</td>
</tr>
))}
</tbody>
</table>
</Skeleton>
</div> </div>
<Pagination <Pagination

View File

@@ -274,9 +274,10 @@ function loadOfferDraft(): {
sections?: unknown[]; sections?: unknown[];
} | null { } | null {
try { try {
const raw = localStorage.getItem("boha_offer_draft"); const raw = localStorage.getItem(DRAFT_KEY);
return raw ? JSON.parse(raw) : null; return raw ? JSON.parse(raw) : null;
} catch { } catch (e) {
console.error("Failed to load offer draft:", e);
return null; return null;
} }
} }
@@ -353,17 +354,28 @@ export default function OfferDetail() {
const draft = loadOfferDraft(); const draft = loadOfferDraft();
if (draft?.form) { if (draft?.form) {
return { return {
...emptyForm, quotation_number:
(draft.form.quotation_number as string) || emptyForm.quotation_number,
project_code: project_code:
(draft.form.project_code as string) || emptyForm.project_code, (draft.form.project_code as string) || emptyForm.project_code,
customer_id:
(draft.form.customer_id as number | null) ?? emptyForm.customer_id,
customer_name: customer_name:
(draft.form.customer_name as string) || emptyForm.customer_name, (draft.form.customer_name as string) || emptyForm.customer_name,
created_at: (draft.form.created_at as string) || emptyForm.created_at, created_at: (draft.form.created_at as string) || emptyForm.created_at,
valid_until: valid_until:
(draft.form.valid_until as string) || emptyForm.valid_until, (draft.form.valid_until as string) || emptyForm.valid_until,
currency: (draft.form.currency as string) || emptyForm.currency, currency: (draft.form.currency as string) || emptyForm.currency,
customer_id: language: (draft.form.language as string) || emptyForm.language,
(draft.form.customer_id as number | null) ?? emptyForm.customer_id, vat_rate: (draft.form.vat_rate as number) ?? emptyForm.vat_rate,
apply_vat: (draft.form.apply_vat as boolean) ?? emptyForm.apply_vat,
exchange_rate:
(draft.form.exchange_rate as string) || emptyForm.exchange_rate,
scope_title:
(draft.form.scope_title as string) || emptyForm.scope_title,
scope_description:
(draft.form.scope_description as string) ||
emptyForm.scope_description,
}; };
} }
return emptyForm; return emptyForm;
@@ -590,22 +602,27 @@ export default function OfferDetail() {
useEffect(() => { useEffect(() => {
if (isEdit) return; if (isEdit) return;
try { try {
const data = JSON.parse(debouncedDraft);
const draft = { const draft = {
form: { form: {
project_code: form.project_code, project_code: data.form.project_code ?? "",
customer_id: form.customer_id, customer_id: data.form.customer_id ?? null,
customer_name: form.customer_name, customer_name: data.form.customer_name ?? "",
created_at: form.created_at, created_at: data.form.created_at ?? "",
valid_until: form.valid_until, valid_until: data.form.valid_until ?? "",
currency: form.currency, currency: data.form.currency ?? "CZK",
language: data.form.language ?? "EN",
vat_rate: data.form.vat_rate ?? 21,
apply_vat: data.form.apply_vat ?? false,
exchange_rate: data.form.exchange_rate ?? "",
}, },
items, items: data.items,
sections, sections: data.sections,
savedAt: new Date().toISOString(), savedAt: new Date().toISOString(),
}; };
localStorage.setItem(DRAFT_KEY, JSON.stringify(draft)); localStorage.setItem(DRAFT_KEY, JSON.stringify(draft));
} catch { } catch (e) {
/* localStorage full or unavailable */ console.error("Failed to save offer draft:", e);
} }
}, [debouncedDraft]); // eslint-disable-line react-hooks/exhaustive-deps }, [debouncedDraft]); // eslint-disable-line react-hooks/exhaustive-deps
@@ -697,8 +714,8 @@ export default function OfferDetail() {
if (!isEdit) { if (!isEdit) {
try { try {
localStorage.removeItem(DRAFT_KEY); localStorage.removeItem(DRAFT_KEY);
} catch { } catch (e) {
/* ignore */ console.error("Failed to remove offer draft:", e);
} }
} }
if (!isEdit && result.data?.id) { if (!isEdit && result.data?.id) {
@@ -1283,7 +1300,7 @@ export default function OfferDetail() {
</p> </p>
)} )}
<div className="admin-table-responsive"> <div className="offers-items-table">
<DndContext <DndContext
sensors={dndSensors} sensors={dndSensors}
collisionDetection={closestCenter} collisionDetection={closestCenter}
@@ -1316,18 +1333,42 @@ export default function OfferDetail() {
<th style={{ width: "2.5rem", textAlign: "center" }}> <th style={{ width: "2.5rem", textAlign: "center" }}>
# #
</th> </th>
<th>Popis</th> <th className="offers-col-desc">Popis</th>
<th style={{ width: "5rem" }}>Množství</th> <th
<th style={{ width: "5rem" }}>Jednotka</th> className="offers-col-qty"
<th style={{ width: "7rem" }}>Cena/ks</th> style={{ width: "5rem" }}
<th style={{ width: "4rem", textAlign: "center" }}> >
Množství
</th>
<th
className="offers-col-unit"
style={{ width: "5rem" }}
>
Jednotka
</th>
<th
className="offers-col-price"
style={{ width: "7rem" }}
>
Cena/ks
</th>
<th
className="offers-col-included"
style={{ width: "4rem", textAlign: "center" }}
>
V ceně V ceně
</th> </th>
<th style={{ width: "7rem", textAlign: "right" }}> <th
className="offers-col-total"
style={{ width: "7rem", textAlign: "right" }}
>
Celkem Celkem
</th> </th>
{!isInvalidated && !isLockedByOther && ( {!isInvalidated && !isLockedByOther && (
<th style={{ width: "3rem" }} /> <th
className="offers-col-del"
style={{ width: "3rem" }}
/>
)} )}
</tr> </tr>
</thead> </thead>

View File

@@ -220,8 +220,14 @@ export default async function attendanceRoutes(
userId, userId,
isAdmin, isAdmin,
authUserId: authData.userId, authUserId: authData.userId,
month: query.month ? Number(query.month) : undefined, month: query.month
year: query.year ? Number(query.year) : undefined, ? Number(String(query.month).split("-")[1])
: undefined,
year: query.month
? Number(String(query.month).split("-")[0])
: query.year
? Number(query.year)
: undefined,
}); });
return reply.send({ return reply.send({