Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2dbacc3bec | ||
|
|
c21f02e5d1 | ||
|
|
f87e110359 | ||
|
|
f1b1329c1b | ||
|
|
88d5d43448 | ||
|
|
4745af3639 | ||
|
|
4258699b73 | ||
|
|
bfd2c59ad3 |
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "app-ts",
|
||||
"version": "2.4.6",
|
||||
"version": "2.4.10",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "app-ts",
|
||||
"version": "2.4.6",
|
||||
"version": "2.4.10",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.102.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-ts",
|
||||
"version": "2.4.6",
|
||||
"version": "2.4.10",
|
||||
"description": "",
|
||||
"main": "dist/server.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import type { CSSProperties } from "react";
|
||||
import { styled } from "@mui/material/styles";
|
||||
import { styled, useTheme } from "@mui/material/styles";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
import Box from "@mui/material/Box";
|
||||
import {
|
||||
GridData,
|
||||
@@ -11,7 +12,8 @@ import {
|
||||
} from "../lib/queries/plan";
|
||||
import type { Project } from "../lib/queries/projects";
|
||||
import PlanRangeChips from "./PlanRangeChips";
|
||||
import { LoadingState } from "../ui";
|
||||
import { LoadingState, Select, Field } from "../ui";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { fonts } from "../theme";
|
||||
|
||||
/**
|
||||
@@ -30,7 +32,10 @@ const PlanGridRoot = styled(Box)(({ theme }) => ({
|
||||
border: `1px solid ${theme.vars!.palette.divider}`,
|
||||
borderRadius: 14,
|
||||
boxShadow: theme.shadows[2],
|
||||
maxHeight: "calc(100dvh - 240px)",
|
||||
// Never collapse below ~5 rows: on a phone in LANDSCAPE 100dvh is only
|
||||
// ~360-400px, so the bare calc left <160px and the cells disappeared
|
||||
// under the sticky header. The grid scrolls internally either way.
|
||||
maxHeight: "max(calc(100dvh - 240px), 360px)",
|
||||
isolation: "isolate",
|
||||
|
||||
"& .plan-grid": {
|
||||
@@ -464,6 +469,15 @@ export default function PlanGrid({
|
||||
pulseKey,
|
||||
onCellClick,
|
||||
}: Props) {
|
||||
const theme = useTheme();
|
||||
// Phone layout: the multi-person grid doesn't fit a portrait viewport, so
|
||||
// a person picker shows ONE column at a time (Datum + selected person).
|
||||
// Desktop/tablet keeps all columns.
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
|
||||
const { user: authUser } = useAuth();
|
||||
// null = "no explicit choice yet" → defaults to the logged-in user when
|
||||
// they are in the plan, else the first person.
|
||||
const [mobileUserId, setMobileUserId] = useState<number | null>(null);
|
||||
const today = useMemo(() => todayIso(), []);
|
||||
const catMap = useMemo(() => categoryMap(categories), [categories]);
|
||||
// Index projects by id once per projects change instead of a linear
|
||||
@@ -483,7 +497,15 @@ export default function PlanGrid({
|
||||
);
|
||||
|
||||
if (!data) return <LoadingState />;
|
||||
const users = data.users;
|
||||
const allUsers = data.users;
|
||||
// Mobile: narrow to the picked person (derived, not stored — a stale pick
|
||||
// after the user list changes falls back gracefully).
|
||||
const mobileUser =
|
||||
allUsers.find((u) => u.id === mobileUserId) ??
|
||||
allUsers.find((u) => u.id === authUser?.id) ??
|
||||
allUsers[0];
|
||||
const users =
|
||||
isMobile && allUsers.length > 1 && mobileUser ? [mobileUser] : allUsers;
|
||||
// pulseKey?.nonce is included in the data-pulse attribute so a second
|
||||
// mutation on the same cell re-triggers the animation (CSS animations
|
||||
// don't restart unless the keyframe applies to a fresh element/class
|
||||
@@ -493,6 +515,21 @@ export default function PlanGrid({
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
{isMobile && allUsers.length > 1 && (
|
||||
<Box sx={{ mb: 1.5 }}>
|
||||
<Field label="Osoba">
|
||||
<Select
|
||||
value={String(mobileUser?.id ?? "")}
|
||||
onChange={(value) => setMobileUserId(Number(value))}
|
||||
options={allUsers.map((u) => ({
|
||||
value: String(u.id),
|
||||
label: u.full_name,
|
||||
}))}
|
||||
/>
|
||||
</Field>
|
||||
</Box>
|
||||
)}
|
||||
<PlanGridRoot data-pulse={pulseAttr}>
|
||||
<table className="plan-grid">
|
||||
{/* The colgroup is what actually controls column width in a
|
||||
@@ -644,5 +681,6 @@ export default function PlanGrid({
|
||||
</tbody>
|
||||
</table>
|
||||
</PlanGridRoot>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -422,7 +422,12 @@ export default function OdinChat() {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
height: "calc(100dvh - 100px)",
|
||||
// svh (NOT dvh): dvh grows live as the mobile URL bar collapses, so a
|
||||
// dvh-sized full-height layout always lets the page scroll by the
|
||||
// browser-chrome height. svh sizes for the bar-visible viewport — the
|
||||
// page never scrolls and the chat's message list scrolls internally.
|
||||
// Desktop: svh === vh.
|
||||
height: "calc(100svh - 100px)",
|
||||
display: "flex",
|
||||
border: 1,
|
||||
borderColor: "divider",
|
||||
|
||||
@@ -1163,7 +1163,16 @@ export default function InvoiceDetail() {
|
||||
mb: 3,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
|
||||
{/* flexWrap: long document titles must drop below the Zpět button
|
||||
on phones instead of overflowing the viewport. */}
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 2,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
component={RouterLink}
|
||||
to="/invoices?tab=issued"
|
||||
@@ -1724,7 +1733,16 @@ export default function InvoiceDetail() {
|
||||
mb: 3,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
|
||||
{/* flexWrap: long document titles must drop below the Zpět button
|
||||
on phones instead of overflowing the viewport. */}
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 2,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
component={RouterLink}
|
||||
to="/invoices?tab=issued"
|
||||
|
||||
@@ -573,7 +573,16 @@ export default function IssuedOrderDetail() {
|
||||
mb: 3,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
|
||||
{/* flexWrap: long document titles must drop below the Zpět button on
|
||||
phones instead of overflowing the viewport. */}
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 2,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
component={RouterLink}
|
||||
to="/orders?tab=vydane"
|
||||
|
||||
@@ -620,7 +620,16 @@ export default function OfferDetail() {
|
||||
mb: 3,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
|
||||
{/* flexWrap: long document titles must drop below the Zpět button
|
||||
on phones instead of overflowing the viewport. */}
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 2,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
component={RouterLink}
|
||||
to="/offers"
|
||||
|
||||
@@ -667,19 +667,29 @@ export default function PlanWork() {
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Mobile: stacked full-width rows (same pattern as headerActionsSx on
|
||||
the detail pages); sm+ keeps the single wrapping toolbar row. The
|
||||
paired controls (Dnes/arrows, Týden/Měsíc) stay together as one
|
||||
full-width row each. */}
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
flexDirection: { xs: "column", sm: "row" },
|
||||
flexWrap: { sm: "wrap" },
|
||||
alignItems: { xs: "stretch", sm: "center" },
|
||||
gap: 1.5,
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
{/* Nav buttons grouped so they stay on one row when the toolbar
|
||||
wraps on mobile. */}
|
||||
<Box sx={{ display: "inline-flex", gap: 1 }}>
|
||||
<Button variant="outlined" color="inherit" onClick={goToToday}>
|
||||
<Box sx={{ display: "flex", gap: 1 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
onClick={goToToday}
|
||||
sx={{ flex: { xs: 1, sm: "0 0 auto" } }}
|
||||
>
|
||||
Dnes
|
||||
</Button>
|
||||
<Button
|
||||
@@ -701,8 +711,8 @@ export default function PlanWork() {
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
minWidth: 220,
|
||||
flex: { sm: 1 },
|
||||
minWidth: { sm: 220 },
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
@@ -727,7 +737,11 @@ export default function PlanWork() {
|
||||
<Box
|
||||
role="group"
|
||||
aria-label="Měřítko zobrazení"
|
||||
sx={{ display: "inline-flex", gap: 1 }}
|
||||
sx={{
|
||||
display: "flex",
|
||||
gap: 1,
|
||||
"& > button": { flex: { xs: 1, sm: "0 0 auto" } },
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant={view === "week" ? "contained" : "outlined"}
|
||||
|
||||
@@ -58,7 +58,13 @@ export const theme = createTheme({
|
||||
h1: { fontFamily: FONT_HEADING, fontWeight: 800 },
|
||||
h2: { fontFamily: FONT_HEADING, fontWeight: 800 },
|
||||
h3: { fontFamily: FONT_HEADING, fontWeight: 800 },
|
||||
h4: { fontFamily: FONT_HEADING, fontWeight: 700 },
|
||||
h4: {
|
||||
fontFamily: FONT_HEADING,
|
||||
fontWeight: 700,
|
||||
// Page/detail headlines: MUI's default 2.125rem overflows phone
|
||||
// viewports (long document titles + number). Scale down on xs only.
|
||||
"@media (max-width:600px)": { fontSize: "1.5rem" },
|
||||
},
|
||||
h5: { fontFamily: FONT_HEADING, fontWeight: 700 },
|
||||
h6: { fontFamily: FONT_HEADING, fontWeight: 700 },
|
||||
button: { textTransform: "none", fontWeight: 600 },
|
||||
|
||||
16
src/main.tsx
16
src/main.tsx
@@ -4,6 +4,22 @@ import { BrowserRouter } from "react-router-dom";
|
||||
import App from "./App";
|
||||
import { ThemeProvider } from "./context/ThemeContext";
|
||||
|
||||
// Deploy-skew recovery (official Vite mechanism): after a release the old
|
||||
// hashed chunks are deleted, so a tab still running the previous build fails
|
||||
// its next lazy-page import. Vite emits `vite:preloadError` for exactly this —
|
||||
// swallow the error and reload once, which loads the fresh index.html and new
|
||||
// chunk names. The timestamp guard prevents a reload loop when a chunk is
|
||||
// genuinely unloadable (e.g. offline): a second failure within 10 s falls
|
||||
// through to the normal error path.
|
||||
window.addEventListener("vite:preloadError", (event) => {
|
||||
const KEY = "vite-preload-reloaded-at";
|
||||
const last = Number(sessionStorage.getItem(KEY) || 0);
|
||||
if (Date.now() - last < 10_000) return;
|
||||
sessionStorage.setItem(KEY, String(Date.now()));
|
||||
event.preventDefault();
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter
|
||||
|
||||
@@ -206,12 +206,35 @@ async function start() {
|
||||
root: path.join(__dirname, "..", "dist-client"),
|
||||
prefix: "/",
|
||||
wildcard: false,
|
||||
// The plugin's own Cache-Control management must be OFF or it
|
||||
// overwrites the setHeaders values below with "public, max-age=0"
|
||||
// (README: "To provide a custom Cache-Control header, set this option
|
||||
// to false"). ETags stay on, so no-cache still revalidates cheaply.
|
||||
cacheControl: false,
|
||||
// Deploy-skew caching contract (per the Vite deploy guide):
|
||||
// - /assets/* names are content-hashed → safe to cache forever
|
||||
// (a changed file always gets a NEW name, so "immutable" is correct).
|
||||
// - index.html (and any non-hashed file) must always be revalidated,
|
||||
// otherwise a cached copy keeps referencing deleted old chunks.
|
||||
setHeaders: (res, filePath) => {
|
||||
if (filePath.includes(`${path.sep}assets${path.sep}`)) {
|
||||
res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
|
||||
} else {
|
||||
res.setHeader("Cache-Control", "no-cache");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
app.setNotFoundHandler((request, reply) => {
|
||||
if (request.url.startsWith("/api/")) {
|
||||
return reply.status(404).send({ success: false, error: "Not found" });
|
||||
}
|
||||
// A missing hashed asset (old chunk after a deploy) must 404, NOT get
|
||||
// the SPA index.html fallback — HTML-as-JS masks the failure and breaks
|
||||
// the client's vite:preloadError reload recovery.
|
||||
if (request.url.startsWith("/assets/")) {
|
||||
return reply.status(404).send();
|
||||
}
|
||||
return reply.sendFile("index.html");
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user