Compare commits
10 Commits
06519d521f
...
v2.4.22
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa7b97689b | ||
|
|
5cd5a9e37d | ||
|
|
27b734f6d0 | ||
|
|
91072548d4 | ||
|
|
5b380863cf | ||
|
|
1293da7f25 | ||
|
|
d9cf8f53e8 | ||
|
|
f509e24942 | ||
|
|
4159ae57a4 | ||
|
|
4c8ed39d85 |
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "app-ts",
|
"name": "app-ts",
|
||||||
"version": "2.4.17",
|
"version": "2.4.22",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "app-ts",
|
"name": "app-ts",
|
||||||
"version": "2.4.17",
|
"version": "2.4.22",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sdk": "^0.102.0",
|
"@anthropic-ai/sdk": "^0.102.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "app-ts",
|
"name": "app-ts",
|
||||||
"version": "2.4.17",
|
"version": "2.4.22",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "dist/server.js",
|
"main": "dist/server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -176,6 +176,57 @@ beforeAll(async () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const FIX_OFFER_NUMBER = "2098/NA/99999";
|
||||||
|
let fixOfferId = 0;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await prisma.quotations.deleteMany({
|
||||||
|
where: { quotation_number: FIX_OFFER_NUMBER },
|
||||||
|
});
|
||||||
|
const q = await prisma.quotations.create({
|
||||||
|
data: {
|
||||||
|
quotation_number: FIX_OFFER_NUMBER,
|
||||||
|
status: "active",
|
||||||
|
currency: "EUR",
|
||||||
|
quotation_items: {
|
||||||
|
create: [
|
||||||
|
{
|
||||||
|
position: 1,
|
||||||
|
description: "Rozvaděč RVO-1",
|
||||||
|
item_description: "<p>Hlavní <b>rozvaděč</b> včetně montáže</p>",
|
||||||
|
quantity: 2,
|
||||||
|
unit: "ks",
|
||||||
|
unit_price: 1000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
position: 2,
|
||||||
|
description: "Doprava (informativní)",
|
||||||
|
quantity: 1,
|
||||||
|
unit: "ks",
|
||||||
|
unit_price: 500,
|
||||||
|
is_included_in_total: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
scope_sections: {
|
||||||
|
create: [
|
||||||
|
{
|
||||||
|
position: 1,
|
||||||
|
title_cz: "Rozsah projektu",
|
||||||
|
content: "<p>Dodávka & montáž <i>na klíč</i>.</p>",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
fixOfferId = q.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
// Items/sections cascade with the quotation.
|
||||||
|
await prisma.quotations.deleteMany({ where: { id: fixOfferId } });
|
||||||
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
// FK-safe order: plan entries carry a Restrict FK on created_by.
|
// FK-safe order: plan entries carry a Restrict FK on created_by.
|
||||||
const fixUserIds = [fixUserId, fixUser2Id];
|
const fixUserIds = [fixUserId, fixUser2Id];
|
||||||
@@ -410,6 +461,56 @@ describe("ai-tools — employees, attendance detail, trips, work plan", () => {
|
|||||||
expect(o.total_km).toBe(52); // foreign 100km trip excluded from totals too
|
expect(o.total_km).toBe(52); // foreign 100km trip excluded from totals too
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("get_offer_detail returns line items, totals and stripped sections", async () => {
|
||||||
|
const res = await executeTool(
|
||||||
|
"get_offer_detail",
|
||||||
|
{ number: "99999" }, // partial number lookup
|
||||||
|
ADMIN,
|
||||||
|
);
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
const r = res.result as {
|
||||||
|
number: string;
|
||||||
|
currency: string;
|
||||||
|
item_count: number;
|
||||||
|
items_total: number;
|
||||||
|
items: {
|
||||||
|
name: string;
|
||||||
|
detail?: string;
|
||||||
|
line_total: number;
|
||||||
|
excluded_from_total?: boolean;
|
||||||
|
}[];
|
||||||
|
sections: { title: string | null; text: string }[];
|
||||||
|
};
|
||||||
|
expect(r.number).toBe(FIX_OFFER_NUMBER);
|
||||||
|
expect(r.currency).toBe("EUR");
|
||||||
|
expect(r.item_count).toBe(2);
|
||||||
|
expect(r.items[0]).toMatchObject({
|
||||||
|
name: "Rozvaděč RVO-1",
|
||||||
|
detail: "Hlavní rozvaděč včetně montáže", // HTML stripped
|
||||||
|
line_total: 2000,
|
||||||
|
});
|
||||||
|
expect(r.items[1].excluded_from_total).toBe(true);
|
||||||
|
expect(r.items_total).toBe(2000); // excluded row not counted
|
||||||
|
expect(r.sections[0]).toMatchObject({ title: "Rozsah projektu" });
|
||||||
|
expect(r.sections[0].text).toContain("Dodávka & montáž na klíč");
|
||||||
|
|
||||||
|
// Unknown number → explicit Czech error, flagged not-ok.
|
||||||
|
const miss = await executeTool(
|
||||||
|
"get_offer_detail",
|
||||||
|
{ number: "NEEXISTUJE-123" },
|
||||||
|
ADMIN,
|
||||||
|
);
|
||||||
|
expect(miss.ok).toBe(false);
|
||||||
|
|
||||||
|
// No offers.view → denied.
|
||||||
|
const denied = await executeTool(
|
||||||
|
"get_offer_detail",
|
||||||
|
{ number: "99999" },
|
||||||
|
NOBODY,
|
||||||
|
);
|
||||||
|
expect(denied.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("get_work_plan resolves the range-entry into per-day rows", async () => {
|
it("get_work_plan resolves the range-entry into per-day rows", async () => {
|
||||||
const res = await executeTool(
|
const res = await executeTool(
|
||||||
"get_work_plan",
|
"get_work_plan",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useRef, useEffect } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import Box from "@mui/material/Box";
|
import Box from "@mui/material/Box";
|
||||||
import Typography from "@mui/material/Typography";
|
import Typography from "@mui/material/Typography";
|
||||||
@@ -109,6 +110,17 @@ export default function OdinChat() {
|
|||||||
});
|
});
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
|
|
||||||
|
// Mobile is immersive (no AppShell header on /odin) — this is the only way
|
||||||
|
// back. A deep link straight to /odin has no in-app history → go home.
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const goBack = () => {
|
||||||
|
if (((window.history.state as { idx?: number } | null)?.idx ?? 0) > 0) {
|
||||||
|
navigate(-1);
|
||||||
|
} else {
|
||||||
|
navigate("/");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// No auto-select: like claude.ai, we land on a fresh "new chat" (activeId
|
// No auto-select: like claude.ai, we land on a fresh "new chat" (activeId
|
||||||
// null) and the sidebar lists existing conversations to open. A conversation
|
// null) and the sidebar lists existing conversations to open. A conversation
|
||||||
// row in the DB is created lazily on the first message (see submit), so
|
// row in the DB is created lazily on the first message (see submit), so
|
||||||
@@ -424,11 +436,21 @@ export default function OdinChat() {
|
|||||||
// browser-chrome height. svh sizes for the bar-visible viewport — the
|
// browser-chrome height. svh sizes for the bar-visible viewport — the
|
||||||
// page never scrolls and the chat's message list scrolls internally.
|
// page never scrolls and the chat's message list scrolls internally.
|
||||||
// Desktop: svh === vh.
|
// Desktop: svh === vh.
|
||||||
height: "calc(100svh - 100px)",
|
// Mobile is immersive (AppShell hides its header and main padding on
|
||||||
|
// /odin): the chat owns the whole viewport, full-bleed. --app-height
|
||||||
|
// is AppShell's live window.innerHeight measurement — standalone-PWA
|
||||||
|
// viewports misreport svh/dvh (MIUI), only the measured value fits.
|
||||||
|
height: {
|
||||||
|
xs: "var(--app-height, 100svh)",
|
||||||
|
md: "calc(100svh - 100px)",
|
||||||
|
},
|
||||||
display: "flex",
|
display: "flex",
|
||||||
border: 1,
|
// Longhand on purpose: a responsive `border` shorthand lands in a
|
||||||
|
// media query AFTER borderColor and resets the color to black.
|
||||||
|
borderStyle: "solid",
|
||||||
|
borderWidth: { xs: 0, md: 1 },
|
||||||
borderColor: "divider",
|
borderColor: "divider",
|
||||||
borderRadius: 3,
|
borderRadius: { xs: 0, md: 3 },
|
||||||
bgcolor: "background.paper",
|
bgcolor: "background.paper",
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
}}
|
}}
|
||||||
@@ -454,15 +476,41 @@ export default function OdinChat() {
|
|||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
gap: 1.5,
|
gap: 1.5,
|
||||||
p: 2,
|
p: 2,
|
||||||
|
// Immersive mobile: respect the notch / home-indicator insets
|
||||||
|
// (env() is 0 outside standalone mode, so max() keeps the 16px).
|
||||||
|
pt: { xs: "max(16px, env(safe-area-inset-top))", md: 2 },
|
||||||
|
pb: { xs: "max(16px, env(safe-area-inset-bottom))", md: 2 },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||||
|
{isMobile && (
|
||||||
|
<IconButton
|
||||||
|
onClick={goBack}
|
||||||
|
aria-label="Zpět"
|
||||||
|
size="small"
|
||||||
|
sx={{ ml: -0.5, flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
component="svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
sx={{ width: 22, height: 22 }}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<line x1="19" y1="12" x2="5" y2="12" />
|
||||||
|
<polyline points="12 19 5 12 12 5" />
|
||||||
|
</Box>
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
{isMobile && (
|
{isMobile && (
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={() => setSidebarOpen(true)}
|
onClick={() => setSidebarOpen(true)}
|
||||||
aria-label="Konverzace"
|
aria-label="Konverzace"
|
||||||
size="small"
|
size="small"
|
||||||
sx={{ ml: -0.5, flexShrink: 0 }}
|
sx={{ flexShrink: 0 }}
|
||||||
>
|
>
|
||||||
<Box
|
<Box
|
||||||
component="svg"
|
component="svg"
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import Box from "@mui/material/Box";
|
import Box from "@mui/material/Box";
|
||||||
import Typography from "@mui/material/Typography";
|
import Typography from "@mui/material/Typography";
|
||||||
import Chip from "@mui/material/Chip";
|
import Chip from "@mui/material/Chip";
|
||||||
import { motion, useReducedMotion, type Variants } from "framer-motion";
|
import {
|
||||||
|
motion,
|
||||||
|
AnimatePresence,
|
||||||
|
useReducedMotion,
|
||||||
|
type Variants,
|
||||||
|
} from "framer-motion";
|
||||||
import type { ChatTurn } from "./types";
|
import type { ChatTurn } from "./types";
|
||||||
import OdinMark from "./OdinMark";
|
import OdinMark from "./OdinMark";
|
||||||
|
|
||||||
@@ -150,12 +155,17 @@ export default function OdinThread({
|
|||||||
return (
|
return (
|
||||||
<MotionBox
|
<MotionBox
|
||||||
key={i}
|
key={i}
|
||||||
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 10 }}
|
initial={
|
||||||
animate={{ opacity: 1, y: 0 }}
|
reduce
|
||||||
transition={{
|
? { opacity: 0 }
|
||||||
duration: reduce ? 0.3 : 0.34,
|
: { opacity: 0, y: 14, scale: 0.9, x: isUser ? 12 : -12 }
|
||||||
ease: [0.16, 1, 0.3, 1],
|
}
|
||||||
}}
|
animate={{ opacity: 1, y: 0, scale: 1, x: 0 }}
|
||||||
|
transition={
|
||||||
|
reduce
|
||||||
|
? { duration: 0.3 }
|
||||||
|
: { type: "spring", stiffness: 460, damping: 32, mass: 0.7 }
|
||||||
|
}
|
||||||
sx={{
|
sx={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
@@ -163,6 +173,9 @@ export default function OdinThread({
|
|||||||
gap: 1,
|
gap: 1,
|
||||||
alignSelf: isUser ? "flex-end" : "flex-start",
|
alignSelf: isUser ? "flex-end" : "flex-start",
|
||||||
maxWidth: "80%",
|
maxWidth: "80%",
|
||||||
|
// The pop grows out of the corner where the bubble is anchored
|
||||||
|
// (next to the avatar / the composer side), not from its centre.
|
||||||
|
transformOrigin: isUser ? "bottom right" : "bottom left",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{!isUser && <OdinMark size={28} />}
|
{!isUser && <OdinMark size={28} />}
|
||||||
@@ -218,25 +231,79 @@ export default function OdinThread({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Busy indicator — same 28px mark and zero row padding as the bubble
|
{/* Busy indicator — a typing bubble (three bouncing dots) anchored in
|
||||||
rows, so the thinking mark sits exactly in the avatar column. */}
|
the avatar column, springing in/out where the reply will land. */}
|
||||||
{busy && (
|
<AnimatePresence>
|
||||||
<Box
|
{busy && (
|
||||||
sx={{
|
<MotionBox
|
||||||
alignSelf: "flex-start",
|
key="busy"
|
||||||
display: "flex",
|
initial={
|
||||||
alignItems: "center",
|
reduce ? { opacity: 0 } : { opacity: 0, y: 10, scale: 0.85 }
|
||||||
gap: 1,
|
}
|
||||||
py: 1,
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
color: "text.secondary",
|
exit={
|
||||||
}}
|
reduce
|
||||||
>
|
? { opacity: 0, transition: { duration: 0.15 } }
|
||||||
<OdinMark size={28} state="thinking" />
|
: {
|
||||||
<Typography variant="caption" sx={{ color: "inherit" }}>
|
opacity: 0,
|
||||||
Pracuji…
|
scale: 0.85,
|
||||||
</Typography>
|
transition: { duration: 0.16, ease: "easeIn" },
|
||||||
</Box>
|
}
|
||||||
)}
|
}
|
||||||
|
transition={
|
||||||
|
reduce
|
||||||
|
? { duration: 0.3 }
|
||||||
|
: { type: "spring", stiffness: 460, damping: 32, mass: 0.7 }
|
||||||
|
}
|
||||||
|
sx={{
|
||||||
|
alignSelf: "flex-start",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "flex-end",
|
||||||
|
gap: 1,
|
||||||
|
transformOrigin: "bottom left",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<OdinMark size={28} state="thinking" />
|
||||||
|
<Box
|
||||||
|
role="status"
|
||||||
|
aria-label="Odin pracuje"
|
||||||
|
sx={{
|
||||||
|
px: 1.5,
|
||||||
|
py: 1.5,
|
||||||
|
borderRadius: 2,
|
||||||
|
boxShadow: 1,
|
||||||
|
bgcolor: "background.paper",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 0.6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{[0, 1, 2].map((i) => (
|
||||||
|
<MotionBox
|
||||||
|
key={i}
|
||||||
|
animate={
|
||||||
|
reduce
|
||||||
|
? { opacity: [0.35, 1, 0.35] }
|
||||||
|
: { y: [0, -4, 0], opacity: [0.35, 1, 0.35] }
|
||||||
|
}
|
||||||
|
transition={{
|
||||||
|
duration: 0.9,
|
||||||
|
repeat: Infinity,
|
||||||
|
delay: i * 0.15,
|
||||||
|
ease: "easeInOut",
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
borderRadius: "50%",
|
||||||
|
bgcolor: "text.secondary",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</MotionBox>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,41 @@ export default function AppShell() {
|
|||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Chat-style pages own the full mobile viewport: under md the shell header
|
||||||
|
// disappears and main loses its padding — the page brings its own back
|
||||||
|
// button. Desktop keeps the normal shell.
|
||||||
|
const immersiveOnMobile = location.pathname === "/odin";
|
||||||
|
|
||||||
|
// Installed-PWA (standalone) viewports lie to CSS units: on MIUI/Android
|
||||||
|
// svh/dvh are computed against a viewport that includes system UI the real
|
||||||
|
// layout viewport doesn't have, leaving scroll room no unit can remove
|
||||||
|
// (and Chrome can serve a stale viewport after minimize/restore). Measure
|
||||||
|
// the truth — window.innerHeight — into --app-height and keep it fresh;
|
||||||
|
// immersive layouts size from it with an svh fallback.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!immersiveOnMobile) return;
|
||||||
|
const root = document.documentElement;
|
||||||
|
const set = () =>
|
||||||
|
root.style.setProperty("--app-height", `${window.innerHeight}px`);
|
||||||
|
set();
|
||||||
|
window.addEventListener("resize", set);
|
||||||
|
window.visualViewport?.addEventListener("resize", set);
|
||||||
|
// Kill pull-to-refresh: a PTR reload lands mid-viewport-settle and
|
||||||
|
// re-races the measurement (Chromium settles without a resize event).
|
||||||
|
// The immersive page is fixed-viewport — the gesture has no use here.
|
||||||
|
const prevRootOverscroll = root.style.overscrollBehaviorY;
|
||||||
|
const prevBodyOverscroll = document.body.style.overscrollBehaviorY;
|
||||||
|
root.style.overscrollBehaviorY = "none";
|
||||||
|
document.body.style.overscrollBehaviorY = "none";
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("resize", set);
|
||||||
|
window.visualViewport?.removeEventListener("resize", set);
|
||||||
|
root.style.removeProperty("--app-height");
|
||||||
|
root.style.overscrollBehaviorY = prevRootOverscroll;
|
||||||
|
document.body.style.overscrollBehaviorY = prevBodyOverscroll;
|
||||||
|
};
|
||||||
|
}, [immersiveOnMobile]);
|
||||||
|
|
||||||
const handleLogout = useCallback(() => {
|
const handleLogout = useCallback(() => {
|
||||||
setLoggingOut(true);
|
setLoggingOut(true);
|
||||||
setMobileOpen(false);
|
setMobileOpen(false);
|
||||||
@@ -74,13 +109,22 @@ export default function AppShell() {
|
|||||||
duration: loggingOut ? 0.4 : 0.25,
|
duration: loggingOut ? 0.4 : 0.25,
|
||||||
ease: [0.4, 0, 0.2, 1],
|
ease: [0.4, 0, 0.2, 1],
|
||||||
}}
|
}}
|
||||||
style={{ minHeight: "100dvh" }}
|
|
||||||
>
|
>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
minHeight: "100dvh",
|
|
||||||
bgcolor: "background.default",
|
bgcolor: "background.default",
|
||||||
|
// Immersive mobile pages must make the DOCUMENT exactly the
|
||||||
|
// small-viewport height and clip it: MIUI/Xiaomi Chrome keeps
|
||||||
|
// 100dvh at the large-viewport size while the URL bar overlays,
|
||||||
|
// so a 100dvh min-height leaves the page scrollable by the bar
|
||||||
|
// height (invisible in desktop emulation). svh + hidden overflow
|
||||||
|
// makes body scroll impossible regardless of dvh interpretation.
|
||||||
|
minHeight: immersiveOnMobile ? { xs: 0, md: "100dvh" } : "100dvh",
|
||||||
|
...(immersiveOnMobile && {
|
||||||
|
height: { xs: "var(--app-height, 100svh)", md: "auto" },
|
||||||
|
overflow: { xs: "hidden", md: "visible" },
|
||||||
|
}),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Drawer
|
<Drawer
|
||||||
@@ -123,6 +167,7 @@ export default function AppShell() {
|
|||||||
sx={{
|
sx={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
|
minHeight: 0,
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
}}
|
}}
|
||||||
@@ -130,7 +175,9 @@ export default function AppShell() {
|
|||||||
<Box
|
<Box
|
||||||
component="header"
|
component="header"
|
||||||
sx={{
|
sx={{
|
||||||
display: "flex",
|
display: immersiveOnMobile
|
||||||
|
? { xs: "none", md: "flex" }
|
||||||
|
: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: 1,
|
gap: 1,
|
||||||
px: 2,
|
px: 2,
|
||||||
@@ -160,7 +207,18 @@ export default function AppShell() {
|
|||||||
<Box sx={{ flex: 1 }} />
|
<Box sx={{ flex: 1 }} />
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
</Box>
|
</Box>
|
||||||
<Box component="main" sx={{ flex: 1, px: { xs: 2, md: 3 }, pb: 4 }}>
|
<Box
|
||||||
|
component="main"
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
px: immersiveOnMobile ? { xs: 0, md: 3 } : { xs: 2, md: 3 },
|
||||||
|
pb: immersiveOnMobile ? { xs: 0, md: 4 } : 4,
|
||||||
|
...(immersiveOnMobile && {
|
||||||
|
minHeight: 0,
|
||||||
|
overflow: { xs: "hidden", md: "visible" },
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import type Anthropic from "@anthropic-ai/sdk";
|
import type Anthropic from "@anthropic-ai/sdk";
|
||||||
import prisma from "../config/database";
|
import prisma from "../config/database";
|
||||||
import { listInvoices, getInvoiceStats } from "./invoices.service";
|
import { listInvoices, getInvoiceStats, getInvoice } from "./invoices.service";
|
||||||
import { listOffers } from "./offers.service";
|
import { listOffers, getOffer } from "./offers.service";
|
||||||
import { listOrders } from "./orders.service";
|
import { listOrders, getOrder } from "./orders.service";
|
||||||
import { listIssuedOrders } from "./issued-orders.service";
|
import { listIssuedOrders, getIssuedOrder } from "./issued-orders.service";
|
||||||
import { listProjects } from "./projects.service";
|
import { listProjects, getProject } from "./projects.service";
|
||||||
import { getBelowMinimumItems } from "./warehouse.service";
|
import { getBelowMinimumItems } from "./warehouse.service";
|
||||||
import { calcWorkedHours } from "./attendance.service";
|
import { calcWorkedHours } from "./attendance.service";
|
||||||
import { resolveGrid, listPlanUsers } from "./plan.service";
|
import { resolveGrid, listPlanUsers } from "./plan.service";
|
||||||
@@ -86,6 +86,77 @@ const hhmm = (d: Date | null): string | null =>
|
|||||||
: null;
|
: null;
|
||||||
const round2 = (n: number): number => Math.round(n * 100) / 100;
|
const round2 = (n: number): number => Math.round(n * 100) / 100;
|
||||||
|
|
||||||
|
/** Quill/HTML rich text → compact plain text for model context. */
|
||||||
|
const stripHtml = (html: string | null | undefined): string =>
|
||||||
|
(html ?? "")
|
||||||
|
.replace(/<[^>]+>/g, " ")
|
||||||
|
.replace(/ /gi, " ")
|
||||||
|
.replace(/&/gi, "&")
|
||||||
|
.replace(/</gi, "<")
|
||||||
|
.replace(/>/gi, ">")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
interface DocItemRow {
|
||||||
|
description: string | null;
|
||||||
|
item_description: string | null;
|
||||||
|
quantity: unknown;
|
||||||
|
unit: string | null;
|
||||||
|
unit_price: unknown;
|
||||||
|
is_included_in_total?: boolean | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared line-item mapping for the document detail tools. Shows up to 50
|
||||||
|
* rows; the items_total still covers ALL included rows (same rule as the
|
||||||
|
* editors: is_included_in_total === false rows don't count).
|
||||||
|
*/
|
||||||
|
const mapDocItems = (rows: DocItemRow[]) => {
|
||||||
|
let total = 0;
|
||||||
|
for (const r of rows) {
|
||||||
|
if (r.is_included_in_total !== false) {
|
||||||
|
total += Number(r.quantity ?? 0) * Number(r.unit_price ?? 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
item_count: rows.length,
|
||||||
|
items_shown: Math.min(rows.length, 50),
|
||||||
|
items: rows.slice(0, 50).map((r) => {
|
||||||
|
const qty = Number(r.quantity ?? 0);
|
||||||
|
const price = Number(r.unit_price ?? 0);
|
||||||
|
const detail = stripHtml(r.item_description);
|
||||||
|
return {
|
||||||
|
name: r.description,
|
||||||
|
...(detail ? { detail: detail.slice(0, 300) } : {}),
|
||||||
|
quantity: qty,
|
||||||
|
unit: r.unit,
|
||||||
|
unit_price: price,
|
||||||
|
line_total: round2(qty * price),
|
||||||
|
...(r.is_included_in_total === false
|
||||||
|
? { excluded_from_total: true }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
items_total: round2(total),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
interface DocSectionRow {
|
||||||
|
title: string | null;
|
||||||
|
title_cz: string | null;
|
||||||
|
content: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rich-text sections → short plain-text excerpts (max 10 × 500 chars). */
|
||||||
|
const mapDocSections = (rows: DocSectionRow[]) =>
|
||||||
|
rows
|
||||||
|
.map((s) => ({
|
||||||
|
title: s.title_cz || s.title || null,
|
||||||
|
text: stripHtml(s.content).slice(0, 500),
|
||||||
|
}))
|
||||||
|
.filter((s) => s.title || s.text)
|
||||||
|
.slice(0, 10);
|
||||||
|
|
||||||
const TOOLS: ToolSpec[] = [
|
const TOOLS: ToolSpec[] = [
|
||||||
{
|
{
|
||||||
permission: "invoices.view",
|
permission: "invoices.view",
|
||||||
@@ -980,6 +1051,236 @@ const TOOLS: ToolSpec[] = [
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
permission: "offers.view",
|
||||||
|
definition: {
|
||||||
|
name: "get_offer_detail",
|
||||||
|
description:
|
||||||
|
"Detail jedné nabídky včetně POLOŽEK (rozpis řádků s množstvím, jednotkovými cenami a součtem) a textových sekcí. Volej, když se uživatel ptá na obsah/položky konkrétní nabídky. Číslo nabídky zjistíš z list_offers nebo od uživatele; koncepty bez čísla detail nemají. Ceny BEZ DPH.",
|
||||||
|
input_schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
number: {
|
||||||
|
type: "string",
|
||||||
|
description: "Číslo nabídky, stačí část (např. 10035)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["number"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler: async (input) => {
|
||||||
|
const number = str(input.number);
|
||||||
|
if (!number) return { error: "Zadej číslo nabídky." };
|
||||||
|
const found = await prisma.quotations.findFirst({
|
||||||
|
where: { quotation_number: { contains: number } },
|
||||||
|
orderBy: { id: "desc" },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!found) return { error: "Nabídka s tímto číslem neexistuje." };
|
||||||
|
const o = await getOffer(found.id);
|
||||||
|
if (!o) return { error: "Nabídka nenalezena." };
|
||||||
|
return {
|
||||||
|
number: o.quotation_number,
|
||||||
|
status: o.status,
|
||||||
|
customer: o.customer_name,
|
||||||
|
project_code: o.project_code,
|
||||||
|
valid_until: o.valid_until,
|
||||||
|
currency: o.currency,
|
||||||
|
order_number: o.order?.order_number ?? null,
|
||||||
|
note: "Ceny jsou bez DPH (nabídka není daňový doklad).",
|
||||||
|
...mapDocItems(o.items),
|
||||||
|
sections: mapDocSections(o.sections),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
permission: "orders.view",
|
||||||
|
definition: {
|
||||||
|
name: "get_order_detail",
|
||||||
|
description:
|
||||||
|
"Detail jedné přijaté objednávky (od zákazníka) včetně POLOŽEK a textových sekcí, s vazbami na nabídku, projekt a fakturu. Číslo objednávky zjistíš z list_orders. Ceny BEZ DPH.",
|
||||||
|
input_schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
number: {
|
||||||
|
type: "string",
|
||||||
|
description: "Číslo objednávky, stačí část",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["number"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler: async (input) => {
|
||||||
|
const number = str(input.number);
|
||||||
|
if (!number) return { error: "Zadej číslo objednávky." };
|
||||||
|
const found = await prisma.orders.findFirst({
|
||||||
|
where: { order_number: { contains: number } },
|
||||||
|
orderBy: { id: "desc" },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!found) return { error: "Objednávka s tímto číslem neexistuje." };
|
||||||
|
const o = await getOrder(found.id);
|
||||||
|
if (!o) return { error: "Objednávka nenalezena." };
|
||||||
|
return {
|
||||||
|
number: o.order_number,
|
||||||
|
customer_order_number: o.customer_order_number,
|
||||||
|
status: o.status,
|
||||||
|
customer: o.customer_name,
|
||||||
|
currency: o.currency,
|
||||||
|
quotation_number: o.quotation_number,
|
||||||
|
project_number: o.project?.project_number ?? null,
|
||||||
|
invoice_number: o.invoice_number,
|
||||||
|
note: "Ceny jsou bez DPH.",
|
||||||
|
...mapDocItems(o.items),
|
||||||
|
sections: mapDocSections(o.sections),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
permission: "orders.view",
|
||||||
|
definition: {
|
||||||
|
name: "get_issued_order_detail",
|
||||||
|
description:
|
||||||
|
"Detail jedné vydané objednávky (nákup u dodavatele) včetně POLOŽEK a obsahu. Číslo zjistíš z list_issued_orders. Ceny BEZ DPH.",
|
||||||
|
input_schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
number: {
|
||||||
|
type: "string",
|
||||||
|
description: "Číslo vydané objednávky, stačí část",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["number"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler: async (input) => {
|
||||||
|
const number = str(input.number);
|
||||||
|
if (!number) return { error: "Zadej číslo vydané objednávky." };
|
||||||
|
const found = await prisma.issued_orders.findFirst({
|
||||||
|
where: { po_number: { contains: number } },
|
||||||
|
orderBy: { id: "desc" },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!found) {
|
||||||
|
return { error: "Vydaná objednávka s tímto číslem neexistuje." };
|
||||||
|
}
|
||||||
|
const o = await getIssuedOrder(found.id);
|
||||||
|
if (!o) return { error: "Vydaná objednávka nenalezena." };
|
||||||
|
return {
|
||||||
|
number: o.po_number,
|
||||||
|
status: o.status,
|
||||||
|
supplier: o.supplier_name,
|
||||||
|
order_date: o.order_date,
|
||||||
|
currency: o.currency,
|
||||||
|
note: "Ceny jsou bez DPH.",
|
||||||
|
...mapDocItems(o.items),
|
||||||
|
sections: mapDocSections(o.sections),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
permission: "invoices.view",
|
||||||
|
definition: {
|
||||||
|
name: "get_invoice_detail",
|
||||||
|
description:
|
||||||
|
"Detail jedné vydané faktury včetně POLOŽEK a rozpisu DPH (základ, DPH, celkem s DPH). Číslo faktury zjistíš z list_invoices.",
|
||||||
|
input_schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
number: {
|
||||||
|
type: "string",
|
||||||
|
description: "Číslo faktury, stačí část",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["number"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler: async (input) => {
|
||||||
|
const number = str(input.number);
|
||||||
|
if (!number) return { error: "Zadej číslo faktury." };
|
||||||
|
const found = await prisma.invoices.findFirst({
|
||||||
|
where: { invoice_number: { contains: number } },
|
||||||
|
orderBy: { id: "desc" },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!found) return { error: "Faktura s tímto číslem neexistuje." };
|
||||||
|
const inv = await getInvoice(found.id);
|
||||||
|
if (!inv) return { error: "Faktura nenalezena." };
|
||||||
|
// Same VAT rule as the invoice editor: per-item vat_rate, applied only
|
||||||
|
// when the header apply_vat switch is on.
|
||||||
|
let net = 0;
|
||||||
|
let vat = 0;
|
||||||
|
for (const it of inv.items) {
|
||||||
|
const line = Number(it.quantity ?? 0) * Number(it.unit_price ?? 0);
|
||||||
|
net += line;
|
||||||
|
if (inv.apply_vat !== false) {
|
||||||
|
vat += line * (Number(it.vat_rate ?? 0) / 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
number: inv.invoice_number,
|
||||||
|
status: inv.status,
|
||||||
|
customer: inv.customer_name,
|
||||||
|
order_number: inv.order_number,
|
||||||
|
issue_date: inv.issue_date,
|
||||||
|
due_date: inv.due_date,
|
||||||
|
currency: inv.currency,
|
||||||
|
net_total: round2(net),
|
||||||
|
vat_total: round2(vat),
|
||||||
|
total_with_vat: round2(net + vat),
|
||||||
|
...mapDocItems(inv.items),
|
||||||
|
sections: mapDocSections(inv.sections),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
permission: "projects.view",
|
||||||
|
definition: {
|
||||||
|
name: "get_project_detail",
|
||||||
|
description:
|
||||||
|
"Detail jednoho projektu: stav, zákazník, odpovědná osoba, termíny, vazby na nabídku/objednávku a poslední poznámky. Číslo projektu zjistíš z list_projects.",
|
||||||
|
input_schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
number: {
|
||||||
|
type: "string",
|
||||||
|
description: "Číslo projektu, stačí část",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["number"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler: async (input) => {
|
||||||
|
const number = str(input.number);
|
||||||
|
if (!number) return { error: "Zadej číslo projektu." };
|
||||||
|
const found = await prisma.projects.findFirst({
|
||||||
|
where: { project_number: { contains: number } },
|
||||||
|
orderBy: { id: "desc" },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!found) return { error: "Projekt s tímto číslem neexistuje." };
|
||||||
|
const p = await getProject(found.id);
|
||||||
|
if (!p) return { error: "Projekt nenalezen." };
|
||||||
|
return {
|
||||||
|
number: p.project_number,
|
||||||
|
name: p.name,
|
||||||
|
status: p.status,
|
||||||
|
customer: p.customer_name,
|
||||||
|
responsible: p.responsible_user_name,
|
||||||
|
start_date: p.start_date,
|
||||||
|
end_date: p.end_date,
|
||||||
|
quotation_number: p.quotation_number,
|
||||||
|
order_number: p.order_number,
|
||||||
|
order_status: p.order_status,
|
||||||
|
description: stripHtml(p.notes).slice(0, 500) || null,
|
||||||
|
recent_notes: p.project_notes.slice(0, 5).map((n) => ({
|
||||||
|
author: n.user_name,
|
||||||
|
date: n.created_at,
|
||||||
|
text: stripHtml(n.content).slice(0, 300),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Tool definitions the caller may use — filtered by their permissions. */
|
/** Tool definitions the caller may use — filtered by their permissions. */
|
||||||
@@ -1042,4 +1343,9 @@ export const TOOL_LABELS: Record<string, string> = {
|
|||||||
find_employee: "Zaměstnanci",
|
find_employee: "Zaměstnanci",
|
||||||
list_trips: "Kniha jízd",
|
list_trips: "Kniha jízd",
|
||||||
get_work_plan: "Plán práce",
|
get_work_plan: "Plán práce",
|
||||||
|
get_offer_detail: "Detail nabídky",
|
||||||
|
get_order_detail: "Detail objednávky",
|
||||||
|
get_issued_order_detail: "Detail obj. vydané",
|
||||||
|
get_invoice_detail: "Detail faktury",
|
||||||
|
get_project_detail: "Detail projektu",
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user