Compare commits
18 Commits
7582cf88f3
...
v2.4.25
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea3b595b32 | ||
|
|
e9b432bec6 | ||
|
|
741c8109fa | ||
|
|
07d9c8d9f7 | ||
|
|
0928b3636e | ||
|
|
738f852168 | ||
|
|
aa7b97689b | ||
|
|
5cd5a9e37d | ||
|
|
27b734f6d0 | ||
|
|
91072548d4 | ||
|
|
5b380863cf | ||
|
|
1293da7f25 | ||
|
|
d9cf8f53e8 | ||
|
|
f509e24942 | ||
|
|
4159ae57a4 | ||
|
|
4c8ed39d85 | ||
|
|
06519d521f | ||
|
|
3c6d175857 |
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "app-ts",
|
||||
"version": "2.4.16",
|
||||
"version": "2.4.25",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "app-ts",
|
||||
"version": "2.4.16",
|
||||
"version": "2.4.25",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.102.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-ts",
|
||||
"version": "2.4.16",
|
||||
"version": "2.4.25",
|
||||
"description": "",
|
||||
"main": "dist/server.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -112,6 +112,16 @@ beforeAll(async () => {
|
||||
},
|
||||
});
|
||||
fixUser2Id = u2.id;
|
||||
// Leave balance for the HR tools (far-future year per fixture hygiene).
|
||||
await prisma.leave_balances.create({
|
||||
data: {
|
||||
user_id: fixUserId,
|
||||
year: 2098,
|
||||
vacation_total: 25,
|
||||
vacation_used: 5,
|
||||
sick_used: 2,
|
||||
},
|
||||
});
|
||||
await prisma.attendance.create({
|
||||
data: {
|
||||
user_id: fixUserId,
|
||||
@@ -176,6 +186,107 @@ beforeAll(async () => {
|
||||
});
|
||||
});
|
||||
|
||||
const FIX_OFFER_NUMBER = "2098/NA/99999";
|
||||
const FIX_SUPPLIER = "AiTest Dodavatel s.r.o.";
|
||||
const FIX_RI_SUPPLIER = "AiTest Supplier s.r.o.";
|
||||
const FIX_CUSTOMER = "AiTest Zákazník s.r.o.";
|
||||
let fixOfferId = 0;
|
||||
let fixSupplierId = 0;
|
||||
let fixRiId = 0;
|
||||
let fixCustomerId = 0;
|
||||
|
||||
beforeAll(async () => {
|
||||
await prisma.quotations.deleteMany({
|
||||
where: { quotation_number: FIX_OFFER_NUMBER },
|
||||
});
|
||||
await prisma.sklad_suppliers.deleteMany({ where: { name: FIX_SUPPLIER } });
|
||||
await prisma.received_invoices.deleteMany({
|
||||
where: { supplier_name: FIX_RI_SUPPLIER },
|
||||
});
|
||||
const sup = await prisma.sklad_suppliers.create({
|
||||
data: { name: FIX_SUPPLIER, ico: "99999998", city: "Brno" },
|
||||
});
|
||||
fixSupplierId = sup.id;
|
||||
// Customer + two projects for the per-customer aggregation tool.
|
||||
await prisma.projects.deleteMany({
|
||||
where: { name: { startsWith: "AiTest Projekt" } },
|
||||
});
|
||||
await prisma.customers.deleteMany({ where: { name: FIX_CUSTOMER } });
|
||||
const cust = await prisma.customers.create({
|
||||
data: { name: FIX_CUSTOMER },
|
||||
});
|
||||
fixCustomerId = cust.id;
|
||||
await prisma.projects.createMany({
|
||||
data: [
|
||||
{ name: "AiTest Projekt 1", customer_id: fixCustomerId },
|
||||
{ name: "AiTest Projekt 2", customer_id: fixCustomerId },
|
||||
],
|
||||
});
|
||||
const ri = await prisma.received_invoices.create({
|
||||
data: {
|
||||
month: 6,
|
||||
year: 2098,
|
||||
supplier_name: FIX_RI_SUPPLIER,
|
||||
invoice_number: "AITEST-RI-1",
|
||||
amount: 1210,
|
||||
vat_rate: 21,
|
||||
vat_amount: 210,
|
||||
currency: "CZK",
|
||||
issue_date: new Date(Date.UTC(2098, 5, 1)),
|
||||
due_date: new Date(Date.UTC(2098, 5, 15)),
|
||||
status: "unpaid",
|
||||
},
|
||||
});
|
||||
fixRiId = ri.id;
|
||||
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 } });
|
||||
await prisma.sklad_suppliers.deleteMany({ where: { id: fixSupplierId } });
|
||||
await prisma.received_invoices.deleteMany({ where: { id: fixRiId } });
|
||||
// Projects before the customer (Restrict FK on projects.customer_id).
|
||||
await prisma.projects.deleteMany({ where: { customer_id: fixCustomerId } });
|
||||
await prisma.customers.deleteMany({ where: { id: fixCustomerId } });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// FK-safe order: plan entries carry a Restrict FK on created_by.
|
||||
const fixUserIds = [fixUserId, fixUser2Id];
|
||||
@@ -410,6 +521,320 @@ describe("ai-tools — employees, attendance detail, trips, work plan", () => {
|
||||
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_leave_balance: own for a recorder; foreign needs balances/manage", async () => {
|
||||
const own = await executeTool(
|
||||
"get_leave_balance",
|
||||
{ year: 2098 },
|
||||
{
|
||||
userId: fixUserId,
|
||||
roleName: "viewer",
|
||||
permissions: ["attendance.record"],
|
||||
},
|
||||
);
|
||||
expect(own.ok).toBe(true);
|
||||
expect(own.result).toMatchObject({
|
||||
user_id: fixUserId,
|
||||
year: 2098,
|
||||
vacation_total: 25,
|
||||
vacation_used: 5,
|
||||
vacation_remaining: 20,
|
||||
sick_used: 2,
|
||||
});
|
||||
|
||||
// Bare attendance.record asking about someone else → denied.
|
||||
const denied = await executeTool(
|
||||
"get_leave_balance",
|
||||
{ user_id: fixUserId, year: 2098 },
|
||||
RECORDER,
|
||||
);
|
||||
expect(denied.ok).toBe(false);
|
||||
|
||||
// attendance.balances (the overview route's permission) unlocks others.
|
||||
const hr = await executeTool(
|
||||
"get_leave_balance",
|
||||
{ user_id: fixUserId, year: 2098 },
|
||||
{
|
||||
userId: 999999998,
|
||||
roleName: "viewer",
|
||||
permissions: ["attendance.balances"],
|
||||
},
|
||||
);
|
||||
expect(hr.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("get_work_fund returns the current-month fund; foreign needs manage", async () => {
|
||||
const res = await executeTool(
|
||||
"get_work_fund",
|
||||
{ user_id: fixUserId },
|
||||
ADMIN,
|
||||
);
|
||||
expect(res.ok).toBe(true);
|
||||
const r = res.result as {
|
||||
fund_hours: number;
|
||||
worked_hours: number;
|
||||
business_days: number;
|
||||
};
|
||||
expect(r.fund_hours).toBeGreaterThan(0);
|
||||
expect(r.business_days).toBeGreaterThan(0);
|
||||
expect(r.worked_hours).toBe(0); // fixture attendance lives in 2098
|
||||
|
||||
const denied = await executeTool(
|
||||
"get_work_fund",
|
||||
{ user_id: fixUserId },
|
||||
RECORDER,
|
||||
);
|
||||
expect(denied.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("get_project_hours_report is manage-gated and aggregates the fixture shift", async () => {
|
||||
const denied = await executeTool("get_project_hours_report", {}, RECORDER);
|
||||
expect(denied.ok).toBe(false);
|
||||
|
||||
// The 2098 fixture shift (8.00 h, no project) lands in the labeled
|
||||
// no-project bucket with per-user hours.
|
||||
const res = await executeTool(
|
||||
"get_project_hours_report",
|
||||
{ year: 2098 },
|
||||
ADMIN,
|
||||
);
|
||||
expect(res.ok).toBe(true);
|
||||
const r = res.result as {
|
||||
year: number;
|
||||
projects: {
|
||||
label: string;
|
||||
hours: number;
|
||||
by_user: Record<string, number>;
|
||||
}[];
|
||||
};
|
||||
expect(r.year).toBe(2098);
|
||||
const bucket = r.projects.find((p) => p.label === "(bez projektu)");
|
||||
expect(bucket).toBeDefined();
|
||||
expect(bucket!.hours).toBe(8);
|
||||
expect(bucket!.by_user[`${FIX.first} ${FIX.last}`]).toBe(8);
|
||||
});
|
||||
|
||||
it("find_supplier + list_vehicles return their compact shapes", async () => {
|
||||
const sup = await executeTool(
|
||||
"find_supplier",
|
||||
{ search: "AiTest Dodavatel" },
|
||||
{ userId: 999999998, roleName: "viewer", permissions: ["orders.view"] },
|
||||
);
|
||||
expect(sup.ok).toBe(true);
|
||||
const s = (
|
||||
sup.result as { suppliers: { name: string; ico: string | null }[] }
|
||||
).suppliers.find((x) => x.name === FIX_SUPPLIER);
|
||||
expect(s).toMatchObject({ ico: "99999998" });
|
||||
|
||||
// IČO typed with spaces finds the space-less stored value.
|
||||
const spacedIco = await executeTool(
|
||||
"find_supplier",
|
||||
{ search: "999 99 998" },
|
||||
{ userId: 999999998, roleName: "viewer", permissions: ["orders.view"] },
|
||||
);
|
||||
expect(
|
||||
(spacedIco.result as { suppliers: { name: string }[] }).suppliers.some(
|
||||
(x) => x.name === FIX_SUPPLIER,
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
const veh = await executeTool(
|
||||
"list_vehicles",
|
||||
{ search: FIX.spz },
|
||||
{ userId: fixUserId, roleName: "viewer", permissions: ["trips.history"] },
|
||||
);
|
||||
expect(veh.ok).toBe(true);
|
||||
const v = (
|
||||
veh.result as {
|
||||
vehicles: { spz: string; current_km: number; trip_count: number }[];
|
||||
}
|
||||
).vehicles[0];
|
||||
// Odometer rule: max trip end_km (1050) over initial_km; 3 fixture trips.
|
||||
expect(v).toMatchObject({ spz: FIX.spz, current_km: 1050, trip_count: 3 });
|
||||
|
||||
// SPZ spacing is normalized: "AITEST 999" finds the stored "AITEST999",
|
||||
// in list_vehicles AND in the list_trips vehicle filter.
|
||||
const spaced = await executeTool(
|
||||
"list_vehicles",
|
||||
{ search: "aitest 999" },
|
||||
{ userId: fixUserId, roleName: "viewer", permissions: ["trips.history"] },
|
||||
);
|
||||
expect(
|
||||
(spaced.result as { vehicles: { spz: string }[] }).vehicles[0]?.spz,
|
||||
).toBe(FIX.spz);
|
||||
const tripsByPlate = await executeTool(
|
||||
"list_trips",
|
||||
{ vehicle: "AITEST 999", date_from: "2098-06-15", date_to: "2098-06-16" },
|
||||
{ userId: fixUserId, roleName: "viewer", permissions: ["trips.history"] },
|
||||
);
|
||||
expect(
|
||||
(tripsByPlate.result as { total_matching: number }).total_matching,
|
||||
).toBe(2);
|
||||
|
||||
expect(
|
||||
(await executeTool("find_supplier", { search: "x" }, NOBODY)).ok,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("get_document_totals sums the WHOLE filtered set per currency", async () => {
|
||||
const res = await executeTool(
|
||||
"get_document_totals",
|
||||
{ type: "offers", search: FIX_OFFER_NUMBER },
|
||||
ADMIN,
|
||||
);
|
||||
expect(res.ok).toBe(true);
|
||||
expect(
|
||||
(res.result as { totals: { currency: string; amount: number }[] }).totals,
|
||||
).toEqual([{ currency: "EUR", amount: 2000 }]); // excluded row not counted
|
||||
|
||||
// orders.view alone cannot read offer totals (per-type re-check).
|
||||
const denied = await executeTool(
|
||||
"get_document_totals",
|
||||
{ type: "offers" },
|
||||
{ userId: 999999998, roleName: "viewer", permissions: ["orders.view"] },
|
||||
);
|
||||
expect(denied.ok).toBe(false);
|
||||
|
||||
// Offers have no period filter — month/year must error, not be ignored.
|
||||
const offersMonth = await executeTool(
|
||||
"get_document_totals",
|
||||
{ type: "offers", month: 6, year: 2026 },
|
||||
ADMIN,
|
||||
);
|
||||
expect(offersMonth.ok).toBe(false);
|
||||
|
||||
// A bare month defaults the year (never an unmarked all-time sum) and
|
||||
// the applied period is echoed back.
|
||||
const bareMonth = await executeTool(
|
||||
"get_document_totals",
|
||||
{ type: "orders", month: 6 },
|
||||
ADMIN,
|
||||
);
|
||||
expect(bareMonth.ok).toBe(true);
|
||||
expect((bareMonth.result as { period: string }).period).toBe(
|
||||
`6/${new Date().getFullYear()}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("get_top_customers aggregates per customer across the whole table", async () => {
|
||||
const res = await executeTool(
|
||||
"get_top_customers",
|
||||
{ type: "projects" },
|
||||
ADMIN,
|
||||
);
|
||||
expect(res.ok).toBe(true);
|
||||
const r = res.result as {
|
||||
customers_with_records: number;
|
||||
top: { customer: string; count: number }[];
|
||||
};
|
||||
const mine = r.top.find((t) => t.customer === FIX_CUSTOMER);
|
||||
expect(mine).toBeDefined();
|
||||
expect(mine!.count).toBe(2);
|
||||
expect(r.customers_with_records).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// invoices.view alone cannot aggregate projects (per-type re-check).
|
||||
const denied = await executeTool(
|
||||
"get_top_customers",
|
||||
{ type: "projects" },
|
||||
VIEWER_INVOICES,
|
||||
);
|
||||
expect(denied.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("find_employee and find_supplier report total_matching", async () => {
|
||||
const emp = await executeTool(
|
||||
"find_employee",
|
||||
{ search: FIX.last },
|
||||
RECORDER,
|
||||
);
|
||||
expect(
|
||||
(emp.result as { total_matching: number }).total_matching,
|
||||
).toBeGreaterThanOrEqual(1);
|
||||
const sup = await executeTool(
|
||||
"find_supplier",
|
||||
{ search: FIX_SUPPLIER },
|
||||
ADMIN,
|
||||
);
|
||||
expect(
|
||||
(sup.result as { total_matching: number }).total_matching,
|
||||
).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("get_received_invoice_detail returns the gross-amount detail", async () => {
|
||||
const res = await executeTool(
|
||||
"get_received_invoice_detail",
|
||||
{ search: "AiTest Supplier" },
|
||||
VIEWER_INVOICES,
|
||||
);
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.result).toMatchObject({
|
||||
supplier: FIX_RI_SUPPLIER,
|
||||
number: "AITEST-RI-1",
|
||||
amount_with_vat: 1210,
|
||||
vat_amount: 210,
|
||||
status: "unpaid",
|
||||
});
|
||||
|
||||
expect(
|
||||
(
|
||||
await executeTool(
|
||||
"get_received_invoice_detail",
|
||||
{ search: "x" },
|
||||
NOBODY,
|
||||
)
|
||||
).ok,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("get_work_plan resolves the range-entry into per-day rows", async () => {
|
||||
const res = await executeTool(
|
||||
"get_work_plan",
|
||||
|
||||
@@ -214,7 +214,8 @@ function SortableItemRow({
|
||||
placeholder="Volitelný"
|
||||
InputProps={{ readOnly }}
|
||||
multiline
|
||||
rows={2}
|
||||
minRows={2}
|
||||
maxRows={16}
|
||||
slotProps={{ htmlInput: { maxLength: itemDescriptionMaxLength } }}
|
||||
sx={{ "& textarea": { resize: "vertical" } }}
|
||||
/>
|
||||
@@ -352,7 +353,8 @@ function SortableItemRow({
|
||||
placeholder="Podrobný popis (volitelný)"
|
||||
InputProps={{ readOnly }}
|
||||
multiline
|
||||
rows={2}
|
||||
minRows={2}
|
||||
maxRows={16}
|
||||
slotProps={{ htmlInput: { maxLength: itemDescriptionMaxLength } }}
|
||||
sx={{
|
||||
"& textarea": {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
@@ -109,6 +110,17 @@ export default function OdinChat() {
|
||||
});
|
||||
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
|
||||
// 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
|
||||
@@ -424,11 +436,21 @@ export default function OdinChat() {
|
||||
// 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)",
|
||||
// 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",
|
||||
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",
|
||||
borderRadius: 3,
|
||||
borderRadius: { xs: 0, md: 3 },
|
||||
bgcolor: "background.paper",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
@@ -454,15 +476,41 @@ export default function OdinChat() {
|
||||
flexDirection: "column",
|
||||
gap: 1.5,
|
||||
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 }}>
|
||||
{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 && (
|
||||
<IconButton
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
aria-label="Konverzace"
|
||||
size="small"
|
||||
sx={{ ml: -0.5, flexShrink: 0 }}
|
||||
sx={{ flexShrink: 0 }}
|
||||
>
|
||||
<Box
|
||||
component="svg"
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
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 OdinMark from "./OdinMark";
|
||||
|
||||
@@ -30,29 +35,6 @@ interface OdinThreadProps {
|
||||
threadRef: React.RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
/** Small Odin avatar shown to the left of assistant bubbles. */
|
||||
function OdinAvatar({ size = 28 }: { size?: number }) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: "50%",
|
||||
bgcolor: "primary.main",
|
||||
color: "common.white",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontWeight: 700,
|
||||
fontSize: size * 0.5,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
O
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OdinThread({
|
||||
turns,
|
||||
busy,
|
||||
@@ -173,12 +155,17 @@ export default function OdinThread({
|
||||
return (
|
||||
<MotionBox
|
||||
key={i}
|
||||
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{
|
||||
duration: reduce ? 0.3 : 0.34,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
}}
|
||||
initial={
|
||||
reduce
|
||||
? { opacity: 0 }
|
||||
: { opacity: 0, y: 14, scale: 0.9, x: isUser ? 12 : -12 }
|
||||
}
|
||||
animate={{ opacity: 1, y: 0, scale: 1, x: 0 }}
|
||||
transition={
|
||||
reduce
|
||||
? { duration: 0.3 }
|
||||
: { type: "spring", stiffness: 460, damping: 32, mass: 0.7 }
|
||||
}
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
@@ -186,9 +173,12 @@ export default function OdinThread({
|
||||
gap: 1,
|
||||
alignSelf: isUser ? "flex-end" : "flex-start",
|
||||
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 && <OdinAvatar size={28} />}
|
||||
{!isUser && <OdinMark size={28} />}
|
||||
<Box
|
||||
sx={{
|
||||
px: 1.5,
|
||||
@@ -241,25 +231,79 @@ export default function OdinThread({
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Busy indicator */}
|
||||
{busy && (
|
||||
<Box
|
||||
sx={{
|
||||
alignSelf: "flex-start",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1,
|
||||
px: 1.5,
|
||||
py: 1,
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
<OdinMark size={22} state="thinking" />
|
||||
<Typography variant="caption" sx={{ color: "inherit" }}>
|
||||
Pracuji…
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{/* Busy indicator — a typing bubble (three bouncing dots) anchored in
|
||||
the avatar column, springing in/out where the reply will land. */}
|
||||
<AnimatePresence>
|
||||
{busy && (
|
||||
<MotionBox
|
||||
key="busy"
|
||||
initial={
|
||||
reduce ? { opacity: 0 } : { opacity: 0, y: 10, scale: 0.85 }
|
||||
}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={
|
||||
reduce
|
||||
? { opacity: 0, transition: { duration: 0.15 } }
|
||||
: {
|
||||
opacity: 0,
|
||||
scale: 0.85,
|
||||
transition: { duration: 0.16, ease: "easeIn" },
|
||||
}
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -317,7 +317,8 @@ function SortableInvoiceRow({
|
||||
onChange={(e) => onUpdate(index, "item_description", e.target.value)}
|
||||
placeholder="Volitelný"
|
||||
multiline
|
||||
rows={2}
|
||||
minRows={2}
|
||||
maxRows={16}
|
||||
sx={{ "& textarea": { resize: "vertical" } }}
|
||||
/>
|
||||
<Box
|
||||
@@ -418,7 +419,8 @@ function SortableInvoiceRow({
|
||||
}
|
||||
placeholder="Podrobný popis (volitelný)"
|
||||
multiline
|
||||
rows={2}
|
||||
minRows={2}
|
||||
maxRows={16}
|
||||
sx={{
|
||||
"& textarea": {
|
||||
fontSize: "0.8rem",
|
||||
|
||||
@@ -23,6 +23,41 @@ export default function AppShell() {
|
||||
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(() => {
|
||||
setLoggingOut(true);
|
||||
setMobileOpen(false);
|
||||
@@ -74,13 +109,22 @@ export default function AppShell() {
|
||||
duration: loggingOut ? 0.4 : 0.25,
|
||||
ease: [0.4, 0, 0.2, 1],
|
||||
}}
|
||||
style={{ minHeight: "100dvh" }}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
minHeight: "100dvh",
|
||||
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
|
||||
@@ -123,6 +167,7 @@ export default function AppShell() {
|
||||
sx={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
minHeight: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
@@ -130,7 +175,9 @@ export default function AppShell() {
|
||||
<Box
|
||||
component="header"
|
||||
sx={{
|
||||
display: "flex",
|
||||
display: immersiveOnMobile
|
||||
? { xs: "none", md: "flex" }
|
||||
: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1,
|
||||
px: 2,
|
||||
@@ -160,7 +207,18 @@ export default function AppShell() {
|
||||
<Box sx={{ flex: 1 }} />
|
||||
<ThemeToggle />
|
||||
</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 />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -291,6 +291,7 @@ function agentSystemPrompt(
|
||||
"Odpovědi piš jako PROSTÝ TEXT — žádný Markdown (žádné tabulky, **tučné**, nadpisy); výčty piš jako řádky s pomlčkou. " +
|
||||
(tools.length > 0
|
||||
? "Máš nástroje POUZE PRO ČTENÍ dat systému — používej je, kdykoli se dotaz týká firemních dat, a odpovídej výhradně z jejich výsledků (nikdy si firemní čísla nevymýšlej). " +
|
||||
"Seznamové nástroje zobrazují max ~20 řádků, ale total_matching je CELKOVÝ počet odpovídajících záznamů — na otázky 'kolik' odpovídej z total_matching. Na 'pro koho nejvíc' a součty používej agregační nástroje (get_top_customers, get_document_totals, get_invoice_stats) — nikdy nesčítej řádky ze seznamů. " +
|
||||
(hasFindEmployee
|
||||
? "Když se dotaz týká konkrétního zaměstnance (docházka, kniha jízd, plán práce), zjisti nejdřív jeho user_id nástrojem find_employee podle jména — neptej se uživatele na ID. "
|
||||
: "") +
|
||||
|
||||
Reference in New Issue
Block a user