Compare commits

...

4 Commits

Author SHA1 Message Date
BOHA
df83eb2091 feat(projects): auto-create NAS folder for order-spawned projects
Manual project creation already creates the 02_PROJEKTY/<number>_<name>
folder (projects.service.createProject). Projects created from an order —
both from an accepted offer (createOrderFromQuotation) and a manual
offer-less order (createOrder) — did not, so they had no NAS folder until
a user opened the project and clicked "create folder" or uploaded a file.

- orders.service: best-effort, non-fatal createProjectFolder after each
  project-creating transaction commits (mirrors createProject; gated on
  isConfigured(), logs on failure, never rolls back the order).
- projects.service: log when the existing auto-create returns false
  (was silently discarded — CLAUDE.md known-issue #4).
- nas-file-manager: optional constructor basePath seam for tests.
- tests: NasFileManager.createProjectFolder coverage — naming, idempotency,
  Czech diacritics, empty name, not-mounted (152 -> 158 tests).

Bump version to 2.0.3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 08:43:59 +02:00
BOHA
9c862034ca chore(release): v2.0.2 — dashboard "today's assignment" card
- New "Vaše dnešní zařazení" dashboard card: shows the logged-in user's
  work-plan entry for today (category + project/site + note, multi-day
  range badge), with a muted empty state.

No DB migrations. tsc -b --noEmit, npm run build, vitest 152/152 clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 22:26:02 +02:00
BOHA
67d62a6df0 fix(dashboard): rename today-plan card to "Vaše dnešní zařazení"
Clearer, formal (matches the app's Vy/Vaše tone) and category-agnostic
(reads fine for work and absence days) so the employee plainly sees what
they're assigned to. Was "Můj plán na dnešek".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 22:25:35 +02:00
BOHA
512f1fd92b feat(dashboard): show the user's work plan for today
Adds a "Můj plán na dnešek" card near the clock-in: for the logged-in
user it shows today's work-plan entry — category (coloured dot + label),
the project/site (the "where"), the note, and a "součást rozsahu …" badge
only for genuinely multi-day ranges. A muted "Pro dnešek nemáte
naplánováno." empty state when nothing is scheduled.

Server: GET /api/admin/dashboard now returns today_plan, reusing
plan.service resolveCell (override-beats-range precedence) for the current
user + today's local (Prague) date, enriched with the category label +
colour so the widget needs no extra query. Gated by attendance.record /
attendance.manage (the plan permission); omitted otherwise. No migration.

Verified in Chrome: renders today's entry (Práce · OBJ-2026-006 — koko)
with the category colour. tsc -b --noEmit, npm run build, vitest 152/152.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 22:22:07 +02:00
9 changed files with 284 additions and 7 deletions

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "app-ts",
"version": "2.0.1",
"version": "2.0.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "app-ts",
"version": "2.0.1",
"version": "2.0.2",
"license": "ISC",
"dependencies": {
"@dnd-kit/core": "^6.3.1",

View File

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

View File

@@ -0,0 +1,70 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import fs from "fs";
import os from "os";
import path from "path";
import { NasFileManager } from "../services/nas-file-manager";
/**
* Folder auto-creation primitive used by both the manual project-create path
* (projects.service createProject) and the order→project paths (orders.service
* createOrder / createOrderFromQuotation). Uses an isolated temp dir via the
* constructor seam so it is deterministic and passes even where the real NAS
* (Z:) is not mounted.
*/
describe("NasFileManager.createProjectFolder", () => {
let tmpBase: string;
let nas: NasFileManager;
beforeEach(() => {
tmpBase = fs.mkdtempSync(path.join(os.tmpdir(), "nas-proj-"));
nas = new NasFileManager(tmpBase);
});
afterEach(() => {
fs.rmSync(tmpBase, { recursive: true, force: true });
});
it("creates a <number>_<name> folder for a new project", () => {
const ok = nas.createProjectFolder("26710001", "Rekonstrukce haly");
expect(ok).toBe(true);
expect(
fs.existsSync(path.join(tmpBase, "26710001_Rekonstrukce_haly")),
).toBe(true);
});
it("is idempotent — a second call does not create a duplicate", () => {
expect(nas.createProjectFolder("26710002", "Hala")).toBe(true);
expect(nas.createProjectFolder("26710002", "Hala")).toBe(true);
const matches = fs
.readdirSync(tmpBase)
.filter((e) => e.startsWith("26710002_"));
expect(matches).toEqual(["26710002_Hala"]);
});
it("preserves Czech diacritics (no transliteration)", () => {
expect(nas.createProjectFolder("26710003", "Měření haly")).toBe(true);
expect(fs.existsSync(path.join(tmpBase, "26710003_Měření_haly"))).toBe(
true,
);
});
it("strips illegal characters and collapses spaces", () => {
expect(nas.createProjectFolder("26710004", "A / B: C")).toBe(true);
expect(fs.existsSync(path.join(tmpBase, "26710004_A_B_C"))).toBe(true);
});
it("handles an empty name without crashing (trailing underscore)", () => {
expect(nas.createProjectFolder("26710005", "")).toBe(true);
expect(fs.existsSync(path.join(tmpBase, "26710005_"))).toBe(true);
});
it("returns false and creates nothing when the NAS base is not mounted", () => {
const missing = new NasFileManager(
path.join(tmpBase, "does-not-exist-subdir"),
);
expect(missing.createProjectFolder("26710006", "X")).toBe(false);
expect(fs.existsSync(path.join(tmpBase, "does-not-exist-subdir"))).toBe(
false,
);
});
});

View File

@@ -0,0 +1,110 @@
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import { Card } from "../../ui";
import { formatDate } from "../../utils/formatters";
/** The logged-in user's resolved work plan for today (see GET /api/admin/dashboard). */
export interface TodayPlan {
shift_date: string;
category: string;
category_label: string;
category_color: string | null;
project_id: number | null;
project_number: string | null;
project_name: string | null;
note: string;
source: "entry" | "override";
rangeFrom: string | null;
rangeTo: string | null;
}
function projectLabel(p: TodayPlan): string {
const parts = [p.project_number, p.project_name].filter(Boolean);
return parts.length > 0 ? parts.join(" — ") : "Bez projektu";
}
/**
* "Vaše dnešní zařazení" — shows the logged-in user's work-plan entry for today
* (category + project/site + note, with a range badge when the day is part of a
* multi-day range). `plan === null` = the user has plan access but nothing is
* scheduled today (muted empty state). Rendered near the clock-in on the
* dashboard; the caller gates it on the plan permission.
*/
export default function DashTodayPlan({ plan }: { plan: TodayPlan | null }) {
const color = plan?.category_color || "var(--mui-palette-primary-main)";
return (
<Card sx={{ mb: 3 }}>
<Typography
variant="subtitle2"
sx={{ mb: 1.5, fontWeight: 600, color: "text.secondary" }}
>
Vaše dnešní zařazení
</Typography>
{plan ? (
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 1,
flexWrap: "wrap",
}}
>
<Box
sx={{
display: "inline-flex",
alignItems: "center",
gap: 0.75,
px: 1.25,
py: 0.5,
borderRadius: 999,
border: 1,
borderColor: "divider",
bgcolor: `color-mix(in srgb, ${color} 12%, transparent)`,
}}
>
<Box
sx={{
width: 10,
height: 10,
borderRadius: "50%",
bgcolor: color,
flexShrink: 0,
}}
/>
<Typography
component="span"
sx={{ fontWeight: 700, fontSize: "0.8rem" }}
>
{plan.category_label}
</Typography>
</Box>
{plan.rangeFrom &&
plan.rangeTo &&
plan.rangeFrom !== plan.rangeTo && (
<Typography variant="caption" color="text.secondary">
součást rozsahu {formatDate(plan.rangeFrom)} {" "}
{formatDate(plan.rangeTo)}
</Typography>
)}
</Box>
<Typography variant="h6" sx={{ lineHeight: 1.25 }}>
{projectLabel(plan)}
</Typography>
{plan.note && (
<Typography variant="body2" color="text.secondary">
{plan.note}
</Typography>
)}
</Box>
) : (
<Typography variant="body2" color="text.secondary">
Pro dnešek nemáte naplánováno.
</Typography>
)}
</Card>
);
}

View File

@@ -19,6 +19,9 @@ import DashActivityFeed from "../components/dashboard/DashActivityFeed";
import DashAttendanceToday from "../components/dashboard/DashAttendanceToday";
import DashProfile from "../components/dashboard/DashProfile";
import DashSessions from "../components/dashboard/DashSessions";
import DashTodayPlan, {
type TodayPlan,
} from "../components/dashboard/DashTodayPlan";
const API_BASE = "/api/admin";
@@ -69,6 +72,7 @@ interface DashData {
pending_orders?: number;
unpaid_invoices?: number;
pending_leave_requests?: number;
today_plan?: TodayPlan | null;
[key: string]: unknown;
}
@@ -317,6 +321,13 @@ export default function Dashboard() {
<DashKpiCards dashData={dashData ?? null} />
)}
{/* My work plan for today — paired with the clock-in below */}
{!dashLoading &&
(hasPermission("attendance.record") ||
hasPermission("attendance.manage")) && (
<DashTodayPlan plan={dashData?.today_plan ?? null} />
)}
{/* Quick actions */}
{!dashLoading && (
<DashQuickActions

View File

@@ -4,6 +4,7 @@ import { requireAuth } from "../../middleware/auth";
import { success } from "../../utils/response";
import { localTimeStr } from "../../utils/date";
import { toCzk } from "../../services/exchange-rates";
import { resolveCell } from "../../services/plan.service";
export default async function dashboardRoutes(
fastify: FastifyInstance,
@@ -42,6 +43,29 @@ export default async function dashboardRoutes(
result.my_shift = { has_ongoing: myShift !== null };
}
// Today's work plan (personal) — powers the "Vaše dnešní zařazení" card.
// resolveCell applies override-beats-range precedence; null = the user has
// plan access but nothing is scheduled today. The category key is enriched
// with its label + colour so the widget needs no extra query. todayStr uses
// local (Europe/Prague) calendar date — the plan's @db.Date day.
if (has("attendance.record") || has("attendance.manage")) {
const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
const cell = await resolveCell(userId, todayStr);
if (cell) {
const cat = await prisma.plan_categories.findUnique({
where: { key: cell.category },
select: { label: true, color: true },
});
result.today_plan = {
...cell,
category_label: cat?.label ?? cell.category,
category_color: cat?.color ?? null,
};
} else {
result.today_plan = null;
}
}
// Attendance admin — only for attendance.manage
if (has("attendance.manage")) {
const [todayAttendance, onLeaveToday, usersCount] = await Promise.all([

View File

@@ -105,8 +105,12 @@ interface DownloadResult {
export class NasFileManager {
private readonly basePath: string;
constructor() {
this.basePath = path.resolve(config.nas.path).replace(/\\/g, "/");
constructor(basePath?: string) {
// basePath is an injection seam for tests; production callers pass nothing
// and get the configured NAS_PATH.
this.basePath = path
.resolve(basePath ?? config.nas.path)
.replace(/\\/g, "/");
}
public isConfigured(): boolean {

View File

@@ -5,6 +5,11 @@ import {
releaseSharedNumber,
isOrderNumberTaken,
} from "./numbering.service";
import { NasFileManager } from "./nas-file-manager";
// Best-effort NAS folder creation for order-spawned projects, mirroring
// projects.service. Stateless singleton; reads NAS_PATH at construction.
const nasFileManager = new NasFileManager();
interface OrderItemInput {
description?: string | null;
@@ -262,6 +267,23 @@ export async function createOrderFromQuotation(
return { order, project, orderNumber };
});
// Best-effort NAS project folder, outside the transaction (mirrors
// createProject). A project created from an accepted offer must get the same
// 02_PROJEKTY/<number>_<name> folder as a manually-created one. Non-fatal: a
// NAS outage must never roll back the order.
if (result.project.project_number && nasFileManager.isConfigured()) {
const created = nasFileManager.createProjectFolder(
result.project.project_number,
result.project.name || "",
);
if (!created) {
console.error(
"[orders.service] NAS folder not created for project",
result.project.project_number,
);
}
}
return {
data: {
order_id: result.order.id,
@@ -297,7 +319,7 @@ export async function createOrder(
attachmentName?: string | null,
) {
try {
return await prisma.$transaction(async (tx) => {
const result = await prisma.$transaction(async (tx) => {
const orderNumber =
body.order_number !== undefined && body.order_number !== null
? String(body.order_number)
@@ -362,6 +384,8 @@ export async function createOrder(
}
let projectId: number | undefined;
let createdProject: { project_number: string; name: string } | null =
null;
// Only auto-create a project for genuine offer-less orders; share the
// order's number (same shared pool) exactly like the from-quotation flow.
if (body.create_project !== false && body.quotation_id == null) {
@@ -375,14 +399,42 @@ export async function createOrder(
},
});
projectId = project.id;
if (project.project_number) {
createdProject = {
project_number: project.project_number,
name: project.name || "",
};
}
}
return {
id: order.id,
order_number: order.order_number,
project_id: projectId,
createdProject,
};
});
// Best-effort NAS project folder, outside the transaction (mirrors
// createProject). Non-fatal so a NAS outage can't roll back the order.
if (result.createdProject && nasFileManager.isConfigured()) {
const created = nasFileManager.createProjectFolder(
result.createdProject.project_number,
result.createdProject.name,
);
if (!created) {
console.error(
"[orders.service] NAS folder not created for project",
result.createdProject.project_number,
);
}
}
return {
id: result.id,
order_number: result.order_number,
project_id: result.project_id,
};
} catch (err) {
if (err instanceof Error && "status" in err) {
return {

View File

@@ -189,10 +189,16 @@ export async function createProject(data: CreateProjectInput) {
});
if (project.project_number && nasFileManager.isConfigured()) {
nasFileManager.createProjectFolder(
const created = nasFileManager.createProjectFolder(
project.project_number,
project.name || "",
);
if (!created) {
console.error(
"[projects.service] NAS folder not created for project",
project.project_number,
);
}
}
return project;