Changes
10 changed files (+715/-2)
-
-
@@ -74,6 +74,7 @@ "@storybook/addon-interactions": "^8.4.7","@storybook/react": "^8.4.7", "@storybook/react-vite": "^8.4.7", "@storybook/test": "^8.4.7", "@types/bun": "^1.1.14", "@types/react": "^19.0.2", "@vitejs/plugin-react": "^4.3.4", "react": "19.x.x",
-
@@ -486,7 +487,7 @@ "@yamori/proto": ["@yamori/proto@workspace:packages/proto", { "devDependencies": { "@bufbuild/protobuf": "^2.2.2", "@bufbuild/protoc-gen-es": "^2.2.2" }, "peerDependencies": { "@bufbuild/protobuf": "^2.2.0" } }],"@yamori/pwa": ["@yamori/pwa@workspace:packages/pwa", { "dependencies": { "@yamori/idb_backend": "packages/idb_backend", "@yamori/react_ui": "packages/react_ui", "react": "^19.0.0", "react-dom": "^19.0.0" }, "devDependencies": { "@types/bun": "^1.1.14", "@types/react": "^19.0.2", "@types/react-dom": "^19.0.2", "@vitejs/plugin-react": "^4.3.4", "rollup-plugin-license": "^3.5.3", "typescript": "^5.7.2", "vite": "^6.0.2", "zod": "^3.24.1" } }], "@yamori/react_ui": ["@yamori/react_ui@workspace:packages/react_ui", { "dependencies": { "@bufbuild/protobuf": "^2.2.2", "@date-fns/tz": "^1.2.0", "@radix-ui/react-collapsible": "^1.1.2", "@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-toast": "^1.2.4", "@radix-ui/themes": "^3.1.6", "@tanstack/react-query": "^5.62.8", "@yamori/idb_backend": "packages/idb_backend", "@yamori/proto": "packages/proto", "date-fns": "^4.1.0", "react-hook-form": "^7.54.2", "urlpattern-polyfill": "^10.0.0" }, "devDependencies": { "@storybook/addon-essentials": "^8.4.7", "@storybook/addon-interactions": "^8.4.7", "@storybook/react": "^8.4.7", "@storybook/react-vite": "^8.4.7", "@storybook/test": "^8.4.7", "@types/react": "^19.0.2", "@vitejs/plugin-react": "^4.3.4", "react": "19.x.x", "react-dom": "^19.0.0", "typescript": "^5.7.2", "vite": "^6.0.2" }, "peerDependencies": { "react": "19.x.x", "react-dom": "19.x.x" } }], "@yamori/react_ui": ["@yamori/react_ui@workspace:packages/react_ui", { "dependencies": { "@bufbuild/protobuf": "^2.2.2", "@date-fns/tz": "^1.2.0", "@radix-ui/react-collapsible": "^1.1.2", "@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-toast": "^1.2.4", "@radix-ui/themes": "^3.1.6", "@tanstack/react-query": "^5.62.8", "@yamori/idb_backend": "packages/idb_backend", "@yamori/proto": "packages/proto", "date-fns": "^4.1.0", "react-hook-form": "^7.54.2", "urlpattern-polyfill": "^10.0.0" }, "devDependencies": { "@storybook/addon-essentials": "^8.4.7", "@storybook/addon-interactions": "^8.4.7", "@storybook/react": "^8.4.7", "@storybook/react-vite": "^8.4.7", "@storybook/test": "^8.4.7", "@types/bun": "^1.1.14", "@types/react": "^19.0.2", "@vitejs/plugin-react": "^4.3.4", "react": "19.x.x", "react-dom": "^19.0.0", "typescript": "^5.7.2", "vite": "^6.0.2" }, "peerDependencies": { "react": "19.x.x", "react-dom": "19.x.x" } }], "acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="],
-
-
-
@@ -104,6 +104,7 @@ "@storybook/addon-interactions": "^8.4.7","@storybook/react": "^8.4.7", "@storybook/react-vite": "^8.4.7", "@storybook/test": "^8.4.7", "@types/bun": "^1.1.14", "@types/react": "^19.0.2", "@vitejs/plugin-react": "^4.3.4", "react": "19.x.x",
-
-
-
@@ -25,6 +25,7 @@ import { useViewTransition } from "../../hooks/useViewTransition.ts";import * as calendar from "./calendar/page.tsx"; import * as paidLeaveProvisionTable from "./paid-leave-provision-table/page.tsx"; import * as summary from "./summary/page.tsx"; import * as workers from "./workers/page.tsx"; import * as workersNew from "./workers/new/page.tsx"; import * as workerDashboard from "./workers/:id/Dashboard.tsx";
-
@@ -123,6 +124,11 @@ <NavigationMenu.Group title="労働者管理"><NavigationMenu.Item current={calendar.pattern.test(url)}> <a href={calendar.href({ workspace })}> <calendar.Title /> </a> </NavigationMenu.Item> <NavigationMenu.Item current={summary.pattern.test(url)}> <a href={summary.href({ workspace })}> <summary.Title /> </a> </NavigationMenu.Item> <NavigationMenu.Item current={workers.pattern.test(url)}>
-
-
-
@@ -19,6 +19,7 @@ import { IllegalMessageError } from "../../errors/IllegalMessageError.ts";import * as calendar from "./calendar/page.tsx"; import * as paidLeaveProvisionTable from "./paid-leave-provision-table/page.tsx"; import * as summary from "./summary/page.tsx"; import * as workers from "./workers/page.tsx"; import * as workersNew from "./workers/new/page.tsx"; import * as workerSubRoute from "./workers/:id/page.tsx";
-
@@ -139,6 +140,10 @@ },{ pattern: calendar.pattern, children: <calendar.Page workspace={workspace} />, }, { pattern: summary.pattern, children: <summary.Page workspace={workspace} />, }, { pattern: paidLeaveProvisionTable.pattern,
-
-
-
@@ -0,0 +1,119 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { describe, test, expect } from "bun:test"; import * as proto from "@bufbuild/protobuf"; import { DateSchema } from "@yamori/proto/yamori/type/v1/date_pb.js"; import { WorkspaceSchema } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import { href, deserializeDate, serializeDate } from "./meta.ts"; describe("serializeDate", () => { test("Should serialize full date", () => { expect(serializeDate({ year: 2020, month: 11, day: 30 })).toBe("2020-11-30"); }); test("Should pad month and day", () => { expect(serializeDate({ year: 2020, month: 1, day: 1 })).toBe("2020-01-01"); }); }); describe("deserializeDate", () => { test("Should deserialize full date", () => { expect(deserializeDate("2020-02-12")).toMatchObject({ year: 2020, month: 2, day: 12, }); }); test("Should deserialize non-0-prefixed numbers", () => { expect(deserializeDate("2020-2-2")).toMatchObject({ year: 2020, month: 2, day: 2, }); }); test("Should not parse non-base10 numbers", () => { expect(deserializeDate("2020-02-ff")).toBeNull(); }); }); describe("href", () => { test("Should not append search part when both since and until are empty", () => { expect( href({ workspace: proto.create(WorkspaceSchema, { id: { value: "ws-foo" }, }), }), ).toBe("/ws-foo/summary"); }); test("Should set since param", () => { expect( href({ workspace: proto.create(WorkspaceSchema, { id: { value: "ws-foo" }, }), since: proto.create(DateSchema, { year: 2020, month: 2, day: 2, }), }), ).toBe("/ws-foo/summary?since=2020-02-02"); }); test("Should set since param", () => { expect( href({ workspace: proto.create(WorkspaceSchema, { id: { value: "ws-foo" }, }), since: proto.create(DateSchema, { year: 2020, month: 2, day: 2, }), }), ).toBe("/ws-foo/summary?since=2020-02-02"); }); test("Should set until param", () => { expect( href({ workspace: proto.create(WorkspaceSchema, { id: { value: "ws-foo" }, }), until: proto.create(DateSchema, { year: 2020, month: 2, day: 2, }), }), ).toBe("/ws-foo/summary?until=2020-02-02"); }); test("Should set search params", () => { expect( href({ workspace: proto.create(WorkspaceSchema, { id: { value: "ws-foo" }, }), since: proto.create(DateSchema, { year: 2020, month: 2, day: 1, }), until: proto.create(DateSchema, { year: 2020, month: 3, day: 1, }), }), ).toBe("/ws-foo/summary?since=2020-02-01&until=2020-03-01"); }); });
-
-
-
@@ -0,0 +1,69 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import "../../../polyfill.ts"; import * as proto from "@bufbuild/protobuf"; import { DateSchema } from "@yamori/proto/yamori/type/v1/date_pb.js"; import { type WorkspaceSchema } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; export function serializeDate( date: Omit<proto.MessageShape<typeof DateSchema>, `$${string}`>, ): string { return `${date.year}-${date.month.toString().padStart(2, "0")}-${date.day.toString().padStart(2, "0")}`; } export function deserializeDate( str: string, ): proto.MessageShape<typeof DateSchema> | null { const [yyyy, mm, dd] = str.split("-"); if (!yyyy || !mm || !dd) { return null; } const year = parseInt(yyyy.replace(/^0*/, ""), 10); const month = parseInt(mm.replace(/^0*/, ""), 10); const day = parseInt(dd.replace(/^0*/, ""), 10); if (!isFinite(year) || !isFinite(month) || !isFinite(day)) { return null; } return proto.create(DateSchema, { year, month, day }); } export interface HrefInput { workspace: proto.MessageShape<typeof WorkspaceSchema>; since?: proto.MessageShape<typeof DateSchema>; until?: proto.MessageShape<typeof DateSchema>; } export function buildSearchParams({ since, until }: Pick<HrefInput, "since" | "until">) { const searchParams = new URLSearchParams(); if (since) { searchParams.set("since", serializeDate(since)); } if (until) { searchParams.set("until", serializeDate(until)); } return searchParams; } export function href({ workspace, since, until }: HrefInput): string { if (!workspace.id) { return "/"; } const base = `/${workspace.id.value}/summary`; const searchParams = buildSearchParams({ since, until }); return searchParams.size > 0 ? base + "?" + searchParams : base; } export const pattern = new URLPattern({ pathname: "/:workspace/summary", });
-
-
-
@@ -0,0 +1,33 @@/* * SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> * SPDX-License-Identifier: AGPL-3.0-only */ .cell { white-space: nowrap; } .sticky { position: sticky; background-color: var(--color-background); z-index: 10; } .columnHeader { composes: cell sticky; top: 0; } .rowHeader { composes: cell sticky; left: 0; } .rowColumnHeader { composes: columnHeader rowHeader; z-index: 11; }
-
-
-
@@ -0,0 +1,49 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import * as proto from "@bufbuild/protobuf"; import { WorkspaceSchema } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import type { Meta, StoryObj } from "@storybook/react"; import { withMockedBackend } from "../../../../.storybook/decorators/withMockedBackend.tsx"; import { withInmemoryRouter } from "../../../../.storybook/decorators/withInmemoryRouter.tsx"; import { Get } from "../../../mocks/yamori/workspace/v1/workspace_service.ts"; import { List } from "../../../mocks/yamori/worker/v1/worker_service.ts"; import { Page } from "./page.tsx"; const workspace = proto.create(WorkspaceSchema, { id: { value: "ws-foo" }, }); export default { component: Page, parameters: { layout: "fullscreen", }, args: { workspace, }, decorators: [ withInmemoryRouter({ initialURL: "/ws-foo/summary" }), withMockedBackend([Get(), List()]), ], } satisfies Meta<typeof Page>; type Story = StoryObj<typeof Page>; export const Success: Story = {}; export const RangeInURL: Story = { decorators: [ withInmemoryRouter({ initialURL: "/ws-foo/summary?since=2020-01-01&until=2020-01-31", }), ], }; export const InvalidDate: Story = { decorators: [ withInmemoryRouter({ initialURL: "/ws-foo/summary?since=2020-01&until=2020-02" }), ], };
-
-
-
@@ -0,0 +1,430 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import * as proto from "@bufbuild/protobuf"; import { tz } from "@date-fns/tz"; import { CaretLeftIcon, CaretRightIcon } from "@radix-ui/react-icons"; import { Button, Box, Container, DropdownMenu, Flex, Link, Table, Text, Tooltip, } from "@radix-ui/themes"; import { DateSchema } from "@yamori/proto/yamori/type/v1/date_pb.js"; import { LeaveSchema } from "@yamori/proto/yamori/work_record/v1/leave_pb.js"; import { WorkspaceSchema } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import { GetRequestSchema } from "@yamori/proto/yamori/workspace/v1/get_request_pb.js"; import { GetResponseSchema } from "@yamori/proto/yamori/workspace/v1/get_response_pb.js"; import { WorkerSchema } from "@yamori/proto/yamori/worker/v1/worker_pb.js"; import { ListRequestSchema } from "@yamori/proto/yamori/worker/v1/list_request_pb.js"; import { ListResponseSchema } from "@yamori/proto/yamori/worker/v1/list_response_pb.js"; import { addMonths, startOfMonth, endOfMonth } from "date-fns"; import { type FC, type ReactNode, use, useEffect, useMemo } from "react"; import { ManagedErrorCallout } from "../../../components/ErrorCallout.ts"; import { NavigationContext, URLContext } from "../../../contexts/Router.tsx"; import { useMethodQuery } from "../../../contexts/Service.tsx"; import { IllegalMessageError } from "../../../errors/IllegalMessageError.ts"; import { fromProtoDate, toProtoDate } from "../../../helpers.ts"; import * as workerDashboard from "../workers/:id/Dashboard.tsx"; import { Layout } from "../Layout.tsx"; import { buildSearchParams, deserializeDate, href } from "./meta.ts"; import css from "./page.module.css"; export { href, pattern } from "./meta.ts"; export const Title: FC = () => "日数明細"; interface DateRange { since: proto.MessageShape<typeof DateSchema>; until: proto.MessageShape<typeof DateSchema>; } interface DayCountProps { suffix?: ReactNode; count: number; } const DayCount: FC<DayCountProps> = ({ suffix = "日", count }) => { if (!count) { return ( <Text size="2" color="gray" style={{ opacity: 0.2 }}> --- </Text> ); } return ( <Text size="3"> {count} <Text ml="1" size="1"> {suffix} </Text> </Text> ); }; const monthFormatter = new Intl.DateTimeFormat(navigator.language, { year: "numeric", month: "numeric", }); interface BodyProps { workspace: proto.MessageShape<typeof WorkspaceSchema>; range: DateRange; onChangeDateRange(range: DateRange): void; } const Body: FC<BodyProps> = ({ workspace, range: { since, until }, onChangeDateRange, }) => { const workspaceConfig = useMethodQuery({ service: "yamori.workspace.v1.WorkspaceService", method: "Get", request: { schema: GetRequestSchema, data: { workspaceId: workspace.id, readMask: { fields: [ WorkspaceSchema.field.abbreviations.number, WorkspaceSchema.field.leaveDefinitions.number, ], leaveDefinitionsMask: { fields: [ LeaveSchema.field.id.number, LeaveSchema.field.displayName.number, LeaveSchema.field.abbreviationName.number, ], }, }, }, }, response: { schema: GetResponseSchema, }, mapResponse(resp) { switch (resp.result.case) { case "ok": return resp.result.value; case undefined: throw new IllegalMessageError(resp); default: throw resp.result.value; } }, }); const workers = useMethodQuery({ service: "yamori.worker.v1.WorkerService", method: "List", request: { schema: ListRequestSchema, data: { workspaceId: workspace.id, workRecordFilter: { since, until, }, readMask: { fields: [ WorkerSchema.field.id.number, WorkerSchema.field.displayName.number, WorkerSchema.field.workRecords.number, ], workRecordsMask: { workspaceDefinedLeaveMask: { fields: [LeaveSchema.field.id.number], }, }, }, }, }, response: { schema: ListResponseSchema, }, mapResponse(resp) { switch (resp.result.case) { case "ok": return resp.result.value.workers; case undefined: throw new IllegalMessageError(resp); default: throw resp.result.value; } }, }); const prevMonth = useMemo(() => { // TODO: TZ をどこか共通の場所で定義する return addMonths(fromProtoDate(since), -1, { in: tz("Asia/Tokyo") }); }, [since]); const nextMonth = useMemo(() => { // TODO: TZ をどこか共通の場所で定義する return addMonths(fromProtoDate(since), 1, { in: tz("Asia/Tokyo") }); }, [since]); return ( <Flex position="absolute" inset="0" direction="column"> {workspaceConfig.isError && ( <ManagedErrorCallout error={workspaceConfig.error} title="ワークスペース設定が取得できませんでした" actions={ <Button size="1" loading={workspaceConfig.isFetching} onClick={() => void workspaceConfig.refetch()} > 再試行 </Button> } /> )} {workers.isError && ( <ManagedErrorCallout error={workers.error} title="労働者一覧を取得できませんでした" actions={ <Button size="1" loading={workspaceConfig.isFetching} onClick={() => void workers.refetch()} > 再試行 </Button> } /> )} <Container p="3" flexShrink="0" flexGrow="0"> <Flex align="center" justify="end" gap="1"> <Button variant="soft" onClick={() => { onChangeDateRange({ // TODO: TZ をどこか共通の場所で定義する since: toProtoDate(startOfMonth(prevMonth, { in: tz("Asia/Tokyo") })), until: toProtoDate(endOfMonth(prevMonth, { in: tz("Asia/Tokyo") })), }); }} > <CaretLeftIcon /> {monthFormatter.format(prevMonth)} </Button> <Button variant="soft" onClick={() => { onChangeDateRange({ // TODO: TZ をどこか共通の場所で定義する since: toProtoDate(startOfMonth(nextMonth, { in: tz("Asia/Tokyo") })), until: toProtoDate(endOfMonth(nextMonth, { in: tz("Asia/Tokyo") })), }); }} > {monthFormatter.format(nextMonth)} <CaretRightIcon /> </Button> <DropdownMenu.Root> <DropdownMenu.Trigger> <Button> ダウンロード <DropdownMenu.TriggerIcon /> </Button> </DropdownMenu.Trigger> <DropdownMenu.Content align="end"> <DropdownMenu.Item disabled>TSVファイル</DropdownMenu.Item> <DropdownMenu.Item disabled>CSVファイル</DropdownMenu.Item> </DropdownMenu.Content> </DropdownMenu.Root> </Flex> </Container> <Box mt="2" p="2" flexGrow="1" flexShrink="1" minWidth="0" minHeight="0"> <Table.Root style={{ maxHeight: "100%" }}> <Table.Header> <Table.Row> <Table.ColumnHeaderCell className={css.rowColumnHeader}> 労働者名 </Table.ColumnHeaderCell> <Table.ColumnHeaderCell className={css.columnHeader} justify="center"> {workspaceConfig.data?.abbreviations?.worked ?? "出勤"} </Table.ColumnHeaderCell> <Table.ColumnHeaderCell className={css.columnHeader} justify="center"> {workspaceConfig.data?.abbreviations?.dayoff ?? "休日"} </Table.ColumnHeaderCell> <Table.ColumnHeaderCell className={css.columnHeader} justify="center"> {workspaceConfig.data?.abbreviations?.skipWork ?? "欠勤"} </Table.ColumnHeaderCell> <Table.ColumnHeaderCell className={css.columnHeader} justify="center"> <Tooltip content="年次有給休暇"> <Text> {workspaceConfig.data?.abbreviations?.paidLeave ?? "年次有給休暇"} </Text> </Tooltip> </Table.ColumnHeaderCell> {workspaceConfig.data?.leaveDefinitions.map((def) => { return ( <Table.ColumnHeaderCell key={def.id?.value} className={css.columnHeader} justify="center" > <Tooltip content={def.displayName}> <Text>{def.abbreviationName || def.displayName}</Text> </Tooltip> </Table.ColumnHeaderCell> ); })} </Table.Row> </Table.Header> <Table.Body> {workers.data?.map((worker) => { let worked = 0; let dayoff = 0; let skipped = 0; let paidLeave = 0; const workspaceDefinedLeaves = new Map<string, number>(); for (const record of worker.workRecords) { switch (record.record.case) { case "workingDay": if (record.record.value.hasWorkerWorked) { worked++; } else { skipped++; } break; case "dayOff": dayoff++; break; case "paidLeave": paidLeave++; break; case "workspaceDefinedLeave": if (record.record.value.id?.value) { const current = workspaceDefinedLeaves.get( record.record.value.id.value, ); workspaceDefinedLeaves.set( record.record.value.id.value, (current ?? 0) + 1, ); } break; } } return ( <Table.Row key={worker.id?.value}> <Table.RowHeaderCell className={css.rowHeader}> <Link href={workerDashboard.href({ workspace, worker })}> {worker.displayName} </Link> </Table.RowHeaderCell> <Table.Cell className={css.cell} justify="center"> <DayCount count={worked} /> </Table.Cell> <Table.Cell className={css.cell} justify="center"> <DayCount count={dayoff} /> </Table.Cell> <Table.Cell className={css.cell} justify="center"> <DayCount count={skipped} /> </Table.Cell> <Table.Cell className={css.cell} justify="center"> <DayCount count={paidLeave} /> </Table.Cell> {workspaceConfig.data?.leaveDefinitions.map((def) => { return ( <Table.Cell key={def.id?.value} className={css.cell} justify="center" > <DayCount count={ (def.id && workspaceDefinedLeaves.get(def.id.value)) || 0 } /> </Table.Cell> ); })} </Table.Row> ); })} </Table.Body> </Table.Root> </Box> </Flex> ); }; export interface PageProps { workspace: proto.MessageShape<typeof WorkspaceSchema>; } export const Page: FC<PageProps> = ({ workspace }) => { const navigation = use(NavigationContext); const url = use(URLContext); const searchParams = useMemo(() => new URLSearchParams(url.search), [url.search]); const range = useMemo<DateRange>(() => { const parseOr = ( paramName: string, fallback: Date, ): proto.MessageShape<typeof DateSchema> => { const param = searchParams.get(paramName); if (!param) { return toProtoDate(fallback); } return deserializeDate(param) || toProtoDate(fallback); }; // TODO: TZ をどこか共通の場所で定義する const since = parseOr("since", startOfMonth(Date.now(), { in: tz("Asia/Tokyo") })); const until = parseOr("until", endOfMonth(Date.now(), { in: tz("Asia/Tokyo") })); return { since, until }; }, [searchParams]); // ブラウザのクエリパラメータと since/until が違う場合はブラウザのクエリパラメータを // 書き換える。 useEffect(() => { const next = buildSearchParams({ since: range.since, until: range.until }); if (next.toString() === searchParams.toString()) { return; } navigation.replace(href({ workspace, since: range.since, until: range.until })); }, [searchParams, range]); return ( <Layout workspace={workspace} title={<Title />}> <Body workspace={workspace} range={range} onChangeDateRange={({ since, until }) => { navigation.replace( href({ workspace, since, until, }), ); }} /> </Layout> ); };
-
-
-
@@ -5,7 +5,7 @@ "moduleResolution": "Bundler","moduleDetection": "force", "noEmit": true, "lib": ["ES2022", "DOM", "DOM.iterable"], "types": ["vite/client"], "types": ["vite/client", "bun"], "jsx": "react-jsx" }, "include": [
-