Changes
6 changed files (+416/-0)
-
-
@@ -26,6 +26,23 @@ export function Get({workspace = { id: { value: "ws-foo" }, displayName: "株式会社あああ", leaveDefinitions: [ { id: { value: "lv-foo" }, displayName: "産前産後休業", isWorkerDeemedToBeWorked: true, }, { id: { value: "lv-bar" }, displayName: "忌引", isWorkerDeemedToBeWorked: false, }, { id: { value: "lv-baz" }, displayName: "リフレッシュ休暇", isWorkerDeemedToBeWorked: true, }, ], }, error = { case: "systemError",
-
-
-
@@ -23,6 +23,7 @@ import { useViewTransition } from "../../hooks/useViewTransition.ts";import * as workers from "./workers/page.tsx"; import * as workersNew from "./workers/new/page.tsx"; import * as leaveDefinitions from "./leave-definitions/page.tsx"; import css from "./Layout.module.css";
-
@@ -110,6 +111,13 @@ <NavigationMenu.Item current={workersNew.pattern.test(url)}><a href={`/${workspace.id?.value}/workers/new`}>労働者登録</a> </NavigationMenu.Item> )} </NavigationMenu.Group> <NavigationMenu.Group title="ワークスペース管理"> <NavigationMenu.Item current={leaveDefinitions.pattern.test(url)}> <a href={`/${workspace.id?.value}/leave-definitions`}> 休暇・休業種別一覧 </a> </NavigationMenu.Item> </NavigationMenu.Group> </NavigationMenu.Root> </ScrollArea>
-
-
-
@@ -0,0 +1,113 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { CardStackPlusIcon, QuestionMarkCircledIcon } from "@radix-ui/react-icons"; import { Badge, Box, Button, Code, DataList, Dialog, Flex, IconButton, Text, VisuallyHidden, } from "@radix-ui/themes"; import { type Leave } from "@yamori/proto/yamori/work_record/v1/leave_pb.js"; import { type FC, type ReactNode } from "react"; export interface ListItemProps { leaveDefinition: Leave; pageTitle: ReactNode; } export const ListItem: FC<ListItemProps> = ({ leaveDefinition, pageTitle }) => { return ( <Box role="listitem"> <DataList.Root orientation="vertical"> <DataList.Item> <DataList.Label>種別名</DataList.Label> <DataList.Value>{leaveDefinition.displayName}</DataList.Value> </DataList.Item> <DataList.Item> <DataList.Label>年次有給休暇付与の出勤率計算時の扱い</DataList.Label> <DataList.Value> <Flex mt="1" align="center" gap="1"> {leaveDefinition.isWorkerDeemedToBeWorked ? ( <Dialog.Root> <Badge color="grass"> <CardStackPlusIcon /> 出勤扱い </Badge> <Dialog.Trigger> <IconButton variant="ghost" color="gray"> <QuestionMarkCircledIcon /> <VisuallyHidden>出勤扱い休暇休業の説明</VisuallyHidden> </IconButton> </Dialog.Trigger> <Dialog.Content> <Dialog.Title>出勤扱いの休暇・休業</Dialog.Title> <Dialog.Description> 年次有給休暇を付与する際の出勤率計算時、この休暇・休業を行った日は出勤したものとして扱います。 </Dialog.Description> <Text as="p" mt="3"> 79 日出勤した所定労働日 100 日の労働者が {leaveDefinition.displayName ? `「${leaveDefinition.displayName}」` : "この休暇・休業"} を行った場合、その時点での出勤率は{" "} <Code>80出勤日 / 100所定労働日 = 80%</Code> となります。 </Text> <Flex mt="5" align="center" justify="end"> <Dialog.Close> <Button>閉じる</Button> </Dialog.Close> </Flex> </Dialog.Content> </Dialog.Root> ) : ( <Dialog.Root> <Badge color="gray">出勤率計上なし</Badge> <Dialog.Trigger> <IconButton variant="ghost" color="gray"> <QuestionMarkCircledIcon /> <VisuallyHidden>出勤率計上なしの休暇休業の説明</VisuallyHidden> </IconButton> </Dialog.Trigger> <Dialog.Content> <Dialog.Title>出勤率計上なしの休暇・休業</Dialog.Title> <Dialog.Description> 年次有給休暇を付与する際の出勤率計算時、この休暇・休業を行った日は欠勤と同様に扱います。 </Dialog.Description> <Text as="p" mt="3"> 79 日出勤した所定労働日 100 日の労働者が {leaveDefinition.displayName ? `「${leaveDefinition.displayName}」` : "この休暇・休業"} を行った場合、その時点での出勤率は{" "} <Code>79出勤日 / 100所定労働日 = 79%</Code> となります。 </Text> <Text as="p" mt="3" size="2" color="gray"> このバッジは{pageTitle}以外では表示されません。 </Text> <Flex mt="5" align="center" justify="end"> <Dialog.Close> <Button>閉じる</Button> </Dialog.Close> </Flex> </Dialog.Content> </Dialog.Root> )} </Flex> </DataList.Value> </DataList.Item> </DataList.Root> </Box> ); };
-
-
-
@@ -0,0 +1,97 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { create } 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 { Page } from "./page.tsx"; const workspace = create(WorkspaceSchema, { id: { value: "ws-foo" }, }); const success = create(WorkspaceSchema, { leaveDefinitions: [ { id: { value: "lv-foo" }, displayName: "なにかの休業", isWorkerDeemedToBeWorked: true, updateKey: { key: new Uint8Array([]) }, }, { id: { value: "lv-bar" }, displayName: "なにかの休暇", isWorkerDeemedToBeWorked: false, }, ], }); export default { component: Page, parameters: { layout: "fullscreen", }, args: { workspace, }, decorators: [ withInmemoryRouter({ initialURL: "/ws-foo/leave-definitions" }), withMockedBackend([ Get({ workspace: success, }), ]), ], } satisfies Meta<typeof Page>; type Story = StoryObj<typeof Page>; export const Success: Story = {}; export const SlowLoad: Story = { decorators: [ withMockedBackend([ Get({ workspace: success, delayMs: 3_000, }), ]), ], }; export const NoDefinitions: Story = { decorators: [ withMockedBackend([ Get({ workspace: {}, }), ]), ], }; export const LoadError: Story = { decorators: [ withMockedBackend([ Get({ workspace: success, failureRate: 1, }), ]), ], }; export const Flaky: Story = { decorators: [ withMockedBackend([ Get({ workspace: success, failureRate: 0.5, }), ]), ], };
-
-
-
@@ -0,0 +1,176 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import "../../../polyfill.ts"; import { QuestionMarkCircledIcon } from "@radix-ui/react-icons"; import { Button, Dialog, Flex, IconButton, Separator, Spinner, Text, VisuallyHidden, } from "@radix-ui/themes"; 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 { WorkspaceSchema, type Workspace, } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import { type FC, Fragment } from "react"; import * as Empty from "../../../components/Empty.ts"; import { ManagedErrorCallout } from "../../../components/ErrorCallout.ts"; import { useMethodQuery } from "../../../contexts/Service.tsx"; import { IllegalMessageError } from "../../../errors/IllegalMessageError.ts"; import { Layout } from "../Layout.tsx"; import { ListItem } from "./ListItem.tsx"; interface BodyProps { workspace: Workspace; } const Body: FC<BodyProps> = ({ workspace: { id } }) => { const definitions = useMethodQuery({ service: "yamori.workspace.v1.WorkspaceService", method: "Get", request: { schema: GetRequestSchema, data: { workspaceId: id, readMask: { fields: [WorkspaceSchema.field.leaveDefinitions.number], }, }, }, response: { schema: GetResponseSchema, }, mapResponse(resp) { switch (resp.result.case) { case "ok": return resp.result.value.leaveDefinitions; case undefined: throw new IllegalMessageError(resp); default: throw resp.result.value; } }, }); if (definitions.data?.length === 0 && !definitions.isError) { return ( <Empty.Root> <Empty.Title>データがありません</Empty.Title> <Empty.Description> このワークスペースには休暇・休業が一つも登録されていません。 </Empty.Description> </Empty.Root> ); } return ( <Flex direction="column" mt="5" gap="5"> {!!definitions.error && ( <ManagedErrorCallout severity={definitions.data ? "warning" : "danger"} title={ definitions.data ? "一覧の更新に失敗しました" : "一覧の取得に失敗しました" } error={definitions.error} actions={ <Button size="1" loading={definitions.isFetching} onClick={() => void definitions.refetch()} > {definitions.data ? "再試行" : "再取得"} </Button> } /> )} {!definitions.data ? ( <Flex mt="5" justify="center" align="center" gap="2"> {definitions.isError ? ( <Text size="2" color="gray"> 取得エラー </Text> ) : ( <> <Spinner /> <Text size="2" color="gray"> 一覧を取得中... </Text> </> )} </Flex> ) : ( <Flex direction="column" gap="4" role="list"> {definitions.data.map((def, i) => ( <Fragment key={def.id?.value}> {i > 0 && <Separator size="4" />} <ListItem leaveDefinition={def} pageTitle={<Title />} /> </Fragment> ))} </Flex> )} </Flex> ); }; export const Title: FC = () => "休暇・休業種別一覧"; export interface PageProps { workspace: Workspace; } export const Page: FC<PageProps> = ({ workspace }) => { return ( <Dialog.Root> <Layout workspace={workspace} title={ <Flex as="span" align="center" gap="2"> <Title /> <Dialog.Trigger> <IconButton variant="ghost" color="gray"> <QuestionMarkCircledIcon /> <VisuallyHidden>このページの説明</VisuallyHidden> </IconButton> </Dialog.Trigger> </Flex> } > <Body workspace={workspace} /> </Layout> <Dialog.Content> <Dialog.Title> <Title /> </Dialog.Title> <Dialog.Description> このワークスペース内で利用可能な休暇と休業の一覧です。 </Dialog.Description> <Text as="p" mt="3"> 労働者の勤怠記録をつける際は、この一覧にある項目から選ぶことができます。 労働者やグループの設定ページで「休暇休業の絞り込み」が設定されている場合は、絞り込まれた項目の中からのみ選ぶことができます。 </Text> <Flex mt="5" align="center" justify="end"> <Dialog.Close> <Button>閉じる</Button> </Dialog.Close> </Flex> </Dialog.Content> </Dialog.Root> ); }; export const pattern = new URLPattern({ pathname: "/:workspace/leave-definitions", });
-
-
-
@@ -19,6 +19,7 @@ import { IllegalMessageError } from "../../errors/IllegalMessageError.ts";import * as workers from "./workers/page.tsx"; import * as workersNew from "./workers/new/page.tsx"; import * as leaveDefinitions from "./leave-definitions/page.tsx"; import { Layout } from "./Layout.tsx"; export const Page: FC = () => {
-
@@ -121,6 +122,10 @@ },{ pattern: workersNew.pattern, children: <workersNew.Page workspace={workspace} />, }, { pattern: leaveDefinitions.pattern, children: <leaveDefinitions.Page workspace={workspace} />, }, ]} fallback={
-