Changes
21 changed files (+1074/-9)
-
-
-
@@ -23,6 +23,7 @@ "react-dom": "19.x.x"}, "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",
-
@@ -30,6 +31,7 @@ "@radix-ui/themes": "^3.1.6","@tanstack/react-query": "^5.62.8", "@yamori/idb_backend": "workspace:*", "@yamori/proto": "workspace:*", "date-fns": "^4.1.0", "react-hook-form": "^7.54.2", "urlpattern-polyfill": "^10.0.0" },
-
-
-
@@ -0,0 +1,33 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import * as proto from "@bufbuild/protobuf"; import { TZDate, tz } from "@date-fns/tz"; import { DateSchema } from "@yamori/proto/yamori/type/v1/date_pb.js"; import { startOfDay, type DateArg } from "date-fns"; // 全ての日付は JST で計算される const TZ = "Asia/Tokyo"; export function fromProtoDate(d: proto.MessageShape<typeof DateSchema>): Date { return TZDate.tz(TZ, d.year, d.month - 1, d.day, 0, 0, 0); } export function toProtoDate<D extends Date>( d: DateArg<D>, ): proto.MessageShape<typeof DateSchema> { const start = startOfDay(d, { in: tz(TZ) }); return proto.create(DateSchema, { year: start.getFullYear(), month: start.getMonth() + 1, day: start.getDate(), }); } export function isSameDate( a: proto.MessageShape<typeof DateSchema>, b: proto.MessageShape<typeof DateSchema>, ): boolean { return a.year === b.year && a.month === b.month && a.day === b.day; }
-
-
-
@@ -3,14 +3,17 @@ // SPDX-License-Identifier: AGPL-3.0-onlyimport { type MessageInitShape } from "@bufbuild/protobuf"; import { type CreateResponseSchema } from "@yamori/proto/yamori/worker/v1/create_response_pb.js"; import { type GetResponseSchema } from "@yamori/proto/yamori/worker/v1/get_response_pb.js"; import { WorkerService } from "@yamori/proto/yamori/worker/v1/worker_service_pb.js"; import { WorkerSchema } from "@yamori/proto/yamori/worker/v1/worker_pb.js"; import { WorkspaceSchema } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import { subDays } from "date-fns"; import { mock, type MockOptions, } from "../../../../../.storybook/decorators/withMockedBackend.tsx"; import { toProtoDate } from "../../../../helpers.ts"; export interface ListOptions extends MockOptions { workspace?: MessageInitShape<typeof WorkspaceSchema>;
-
@@ -121,3 +124,181 @@ },options, ); } export interface GetOptions extends MockOptions { failureRate?: number; worker?: MessageInitShape<typeof WorkerSchema>; error?: Exclude< MessageInitShape<typeof GetResponseSchema>["result"], { case: "ok" | undefined } >; } export function Get({ failureRate = 0, error = { case: "systemError", value: { code: "MOCK_ERR", message: "This is mock error message for yamori.worker.v1.WorkerService.Get", }, }, worker = { id: { value: "wr-alice" }, displayName: "Alice", paidLeaveProvisions: [ { providedAt: { year: 2024, month: 3, day: 1, }, expiresAt: { year: 2026, month: 3, day: 1, }, amountDays: 10, remainingDays: 3, isHalvedDayRemaining: true, }, { providedAt: { year: 2025, month: 3, day: 1, }, expiresAt: { year: 2027, month: 3, day: 1, }, amountDays: 10, remainingDays: 10, }, ], workRecords: [ { date: toProtoDate(subDays(Date.now(), 7)), record: { case: "dayOff", value: {}, }, note: "やる気がないので休日", }, { date: toProtoDate(subDays(Date.now(), 6)), record: { case: "paidLeave", value: { providedAt: { year: 2024, month: 3, day: 1, }, }, }, }, { date: toProtoDate(subDays(Date.now(), 5)), record: { case: "legalLeave", value: { displayName: "産前産後休業", currentRevision: { snapshot: { isWorkerDeemedToBeWorked: true, }, }, }, }, }, { date: toProtoDate(subDays(Date.now(), 4)), record: { case: "specialLeave", value: { displayName: "冠婚葬祭", }, }, }, { date: toProtoDate(subDays(Date.now(), 3)), record: { case: "workingDay", value: { hasWorkerWorked: false, }, }, note: "寝坊", }, { date: toProtoDate(subDays(Date.now(), 2)), record: { case: "workingDay", value: { hasWorkerWorked: true, }, }, }, { date: toProtoDate(subDays(Date.now(), 1)), record: { case: "workingDay", value: { hasWorkerWorked: true, timeOffs: [ { kind: { case: "halvedPaidLeave", value: { providedAt: { year: 2024, month: 3, day: 1, }, }, }, }, { kind: { case: "hourlyPaidLeave", value: { providedAt: { year: 2024, month: 3, day: 1, }, hours: 2, }, }, }, ], }, }, }, ], }, ...options }: GetOptions = {}) { return mock( WorkerService.method.get, () => { if (Math.random() < failureRate) { return { result: error, }; } return { result: { case: "ok" as const, value: worker, }, }; }, options, ); }
-
-
-
@@ -13,6 +13,7 @@ IconButton,ScrollArea, Text, } from "@radix-ui/themes"; import { type Worker } from "@yamori/proto/yamori/worker/v1/worker_pb.js"; import { type Workspace } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import { type FC, type ReactNode, use, useCallback, useState } from "react";
-
@@ -24,6 +25,7 @@ import { useViewTransition } from "../../hooks/useViewTransition.ts";import * as workers from "./workers/page.tsx"; import * as workersNew from "./workers/new/page.tsx"; import * as workerDashboard from "./workers/:id/Dashboard.tsx"; import * as leaveDefinitions from "./leave-definitions/page.tsx"; import * as leaveDefinitionsNew from "./leave-definitions/new/page.tsx";
-
@@ -41,6 +43,8 @@defaultMenuOpened?: boolean; workspace: Workspace; worker?: Worker; } export const Layout: FC<LayoutProps> = ({
-
@@ -49,6 +53,7 @@ title,actions, defaultMenuOpened = false, workspace, worker, ...rest }) => { const viewTransition = useViewTransition();
-
@@ -120,6 +125,15 @@ {workspace.workerAddKey && (<NavigationMenu.Item current={workersNew.pattern.test(url)}> <a href={`/${workspace.id?.value}/workers/new`}>労働者登録</a> </NavigationMenu.Item> )} {worker && ( <NavigationMenu.Group title={worker.displayName || "労働者メニュー"}> <NavigationMenu.Item current={workerDashboard.pattern.test(url)}> <a href={workerDashboard.href({ workspace, worker })}> <workerDashboard.Title /> </a> </NavigationMenu.Item> </NavigationMenu.Group> )} </NavigationMenu.Group> <NavigationMenu.Group title="ワークスペース管理">
-
-
-
@@ -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 workerSubRoute from "./workers/:id/page.tsx"; import * as leaveDefinitions from "./leave-definitions/page.tsx"; import * as leaveDefinitionsNew from "./leave-definitions/new/page.tsx"; import { Layout } from "./Layout.tsx";
-
@@ -132,6 +133,10 @@ },{ pattern: leaveDefinitionsNew.pattern, children: <leaveDefinitionsNew.Page workspace={workspace} />, }, { pattern: workerSubRoute.pattern, children: <workerSubRoute.Page workspace={workspace} />, }, ]} fallback={
-
-
-
@@ -0,0 +1,214 @@// 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 { Box, Flex, Heading, Separator } from "@radix-ui/themes"; import { LeaveSchema } from "@yamori/proto/yamori/work_record/v1/leave_pb.js"; import { LeaveReadMaskSchema } from "@yamori/proto/yamori/work_record/v1/leave_read_mask_pb.js"; import { WorkRecordSchema } from "@yamori/proto/yamori/work_record/v1/work_record_pb.js"; import { PaidLeaveProvisionSchema } from "@yamori/proto/yamori/worker/v1/paid_leave_provision_pb.js"; import { type Worker, WorkerSchema } from "@yamori/proto/yamori/worker/v1/worker_pb.js"; import { WorkerReadMaskSchema } from "@yamori/proto/yamori/worker/v1/worker_read_mask_pb.js"; import { type Workspace } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import { GetRequestSchema } from "@yamori/proto/yamori/worker/v1/get_request_pb.js"; import { GetResponseSchema } from "@yamori/proto/yamori/worker/v1/get_response_pb.js"; import { subDays } from "date-fns"; import { type FC, Fragment, useMemo } from "react"; import { useMethodQuery } from "../../../../contexts/Service.tsx"; import { IllegalMessageError } from "../../../../errors/IllegalMessageError.ts"; import { isSameDate, toProtoDate } from "../../../../helpers.ts"; import { Layout } from "../../Layout.tsx"; import { DatesViewer } from "./Dashboard/DatesViewer.tsx"; import { DatesViewerCell } from "./Dashboard/DatesViewerCell.tsx"; import { PaidLeaves } from "./Dashboard/PaidLeaves.tsx"; import { useDateCells } from "./Dashboard/useDateCells.ts"; export const Title: FC = () => "ダッシュボード"; const leaveReadMask = proto.create(LeaveReadMaskSchema, { fields: [ LeaveSchema.field.id.number, LeaveSchema.field.displayName.number, LeaveSchema.field.currentRevision.number, ], }); const readMask = proto.create(WorkerReadMaskSchema, { fields: [ WorkerSchema.field.writeWorkRecordKey.number, WorkerSchema.field.providePaidLeaveKey.number, WorkerSchema.field.workRecords.number, WorkerSchema.field.paidLeaveProvisions.number, ], paidLeaveProvisionsMask: { fields: [ PaidLeaveProvisionSchema.field.providedAt.number, PaidLeaveProvisionSchema.field.expiresAt.number, PaidLeaveProvisionSchema.field.note.number, PaidLeaveProvisionSchema.field.amountDays.number, PaidLeaveProvisionSchema.field.remainingDays.number, PaidLeaveProvisionSchema.field.isHalvedDayRemaining.number, ], }, workRecordsMask: { fields: [ WorkRecordSchema.field.date.number, WorkRecordSchema.field.workingDay.number, WorkRecordSchema.field.dayOff.number, WorkRecordSchema.field.paidLeave.number, WorkRecordSchema.field.legalLeave.number, WorkRecordSchema.field.specialLeave.number, WorkRecordSchema.field.note.number, ], specialLeaveReadMask: leaveReadMask, legalLeaveReadMask: leaveReadMask, }, }); const loadingWorkerData = proto.create(WorkerSchema, { paidLeaveProvisions: [ { providedAt: { year: 1088, month: 1, day: 1 }, expiresAt: { year: 1088, month: 1, day: 1 }, amountDays: 10, remainingDays: 8, isHalvedDayRemaining: true, }, { providedAt: { year: 1089, month: 1, day: 2 }, expiresAt: { year: 1089, month: 1, day: 2 }, amountDays: 10, remainingDays: 10, }, ], }); interface BodyProps { workspace: Workspace; worker: Worker; daysToDisplay?: number; } const Body: FC<BodyProps> = ({ workspace, worker, daysToDisplay = 7 }) => { const now = new Date(); const [today, since] = useMemo(() => { return [toProtoDate(now), toProtoDate(subDays(now, daysToDisplay))]; }, [now.getDay()]); const dates = useDateCells(since, today); const query = useMethodQuery({ service: "yamori.worker.v1.WorkerService", method: "Get", request: { schema: GetRequestSchema, data: { workspaceId: workspace.id, workerId: worker.id, readMask: readMask, workRecordFilter: { since: since, until: today, }, paidLeaveProvisionFilter: { providedAtUntil: today, expiresAtSince: today, }, }, }, 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; } }, }); return ( <Flex direction="column"> <Heading as="h2" size="2" mt="4" mb="2"> 年次有給休暇 </Heading> <PaidLeaves worker={query.data || loadingWorkerData} loading={!query.data} /> <Heading as="h2" size="2" mt="6" mb="2"> 直近{daysToDisplay}日間の勤怠 </Heading> <DatesViewer> {dates.map((date, i) => ( <Fragment key={i}> {i > 0 && ( <Box asChild flexShrink="0" flexGrow="0"> <Separator orientation={{ initial: "horizontal", sm: "vertical" }} size="4" /> </Box> )} <DatesViewerCell date={date} workRecord={query.data?.workRecords.find( (r) => r.date && isSameDate(r.date, date), )} first={i === 0} flexBasis="100%" flexGrow="1" flexShrink="1" minWidth="0" py={{ initial: "3", sm: "0" }} px={{ initial: "1", sm: "2" }} height="100%" /> </Fragment> ))} </DatesViewer> </Flex> ); }; export interface PageProps { workspace: Workspace; worker: Worker; } export const Page: FC<PageProps> = ({ workspace, worker }) => { return ( <Layout workspace={workspace} worker={worker} title={<Title />}> <Body workspace={workspace} worker={worker} /> </Layout> ); }; export interface HrefInput { workspace: Workspace; worker: Worker; } export function href({ workspace, worker }: HrefInput): string { if (!workspace.id) { return "/"; } if (!worker.id) { return `${workspace.id.value}/workers`; } return `${workspace.id.value}/workers/${worker.id.value}`; } export const pattern = new URLPattern({ pathname: "/:workspace/workers/:worker", });
-
-
-
@@ -0,0 +1,21 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { Flex } from "@radix-ui/themes"; import { type FC, type ReactNode } from "react"; export interface DatesViewerProps { children?: ReactNode; } export const DatesViewer: FC<DatesViewerProps> = ({ children }) => { return ( <Flex height={{ initial: undefined, sm: "10em" }} align="stretch" direction={{ initial: "column", sm: "row" }} > {children} </Flex> ); };
-
-
-
@@ -0,0 +1,36 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import * as proto from "@bufbuild/protobuf"; import type { Meta, StoryObj } from "@storybook/react"; import { DateSchema } from "@yamori/proto/yamori/type/v1/date_pb.js"; import { WorkRecordSchema } from "@yamori/proto/yamori/work_record/v1/work_record_pb.js"; import { DatesViewerCell } from "./DatesViewerCell.tsx"; export default { component: DatesViewerCell, args: { first: false, date: proto.create(DateSchema, { year: 2020, month: 1, day: 1, }), }, } satisfies Meta<typeof DatesViewerCell>; type Story = StoryObj<typeof DatesViewerCell>; export const Empty: Story = {}; export const DayOff: Story = { args: { workRecord: proto.create(WorkRecordSchema, { record: { case: "dayOff", value: {}, }, }), }, };
-
-
-
@@ -0,0 +1,129 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import * as proto from "@bufbuild/protobuf"; import { QuestionMarkIcon } from "@radix-ui/react-icons"; import { Badge, Flex, type FlexProps, Text } from "@radix-ui/themes"; import { DateSchema } from "@yamori/proto/yamori/type/v1/date_pb.js"; import { WorkRecordSchema } from "@yamori/proto/yamori/work_record/v1/work_record_pb.js"; import { type FC } from "react"; interface WorkRecordRendererProps { workRecord?: proto.MessageShape<typeof WorkRecordSchema>; } const WorkRecordRenderer: FC<WorkRecordRendererProps> = ({ workRecord }) => { switch (workRecord?.record.case) { case "workingDay": { const { hasWorkerWorked, timeOffs } = workRecord.record.value; return ( <> {hasWorkerWorked ? ( <Badge color="blue"> <Text truncate>出勤</Text> </Badge> ) : ( <Badge color="amber"> <Text truncate>欠勤</Text> </Badge> )} {timeOffs.map((timeOff, i) => { switch (timeOff.kind.case) { case "halvedPaidLeave": return ( <Badge key={i} color="green"> <Text truncate>半休</Text> </Badge> ); case "hourlyPaidLeave": return ( <Badge key={i} color="green"> <Text truncate>時間単位年休</Text> </Badge> ); default: return ( <Badge key={i} color="gray"> <Text truncate> <QuestionMarkIcon /> 不明なレコード </Text> </Badge> ); } })} </> ); } case "dayOff": return ( <Badge color="red"> <Text truncate>休日</Text> </Badge> ); case "paidLeave": return ( <Badge color="green"> <Text truncate>有給休暇</Text> </Badge> ); case "specialLeave": case "legalLeave": return ( <Badge color={ workRecord.record.value.currentRevision?.snapshot?.isWorkerDeemedToBeWorked ? "green" : "amber" } > <Text truncate> {workRecord.record.value.displayName || (workRecord.record.case === "specialLeave" ? "特別休暇" : "法定休暇")} </Text> </Badge> ); case undefined: return ( <Text truncate size="1" color="gray"> 未入力 </Text> ); default: return ( <Badge color="gray"> <Text truncate>不明なデータ</Text> </Badge> ); } }; export interface DatesViewerCellProps extends Omit<FlexProps, "as" | "asChild" | "direction"> { date: proto.MessageShape<typeof DateSchema>; workRecord?: proto.MessageShape<typeof WorkRecordSchema>; first?: boolean; } export const DatesViewerCell: FC<DatesViewerCellProps> = ({ date, workRecord, first = false, ...rest }) => { return ( <Flex {...rest} direction="column" gap="1"> <Text size="1" style={{ visibility: first || date.day === 1 ? "visible" : "hidden" }} > {date.year}/{date.month} </Text> <Text size="2" weight="bold"> {date.day} </Text> <WorkRecordRenderer workRecord={workRecord} /> </Flex> ); };
-
-
-
@@ -0,0 +1,69 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import * as proto from "@bufbuild/protobuf"; import type { Meta, StoryObj } from "@storybook/react"; import { WorkerSchema } from "@yamori/proto/yamori/worker/v1/worker_pb.js"; import { PaidLeaves } from "./PaidLeaves.tsx"; export default { component: PaidLeaves, args: { worker: proto.create(WorkerSchema, { paidLeaveProvisions: [ { providedAt: { year: 2024, month: 3, day: 1, }, expiresAt: { year: 2026, month: 3, day: 1, }, amountDays: 10, remainingDays: 3, isHalvedDayRemaining: true, }, { providedAt: { year: 2025, month: 3, day: 1, }, expiresAt: { year: 2027, month: 3, day: 1, }, amountDays: 10, remainingDays: 10, }, ], }), loading: false, }, argTypes: { worker: { control: false, }, }, } satisfies Meta<typeof PaidLeaves>; type Story = StoryObj<typeof PaidLeaves>; export const Default: Story = {}; export const Empty: Story = { args: { worker: proto.create(WorkerSchema), }, }; export const Loading: Story = { args: { loading: true, }, };
-
-
-
@@ -0,0 +1,83 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { InfoCircledIcon } from "@radix-ui/react-icons"; import { Box, Card, Flex, Skeleton, Text } from "@radix-ui/themes"; import { type Worker } from "@yamori/proto/yamori/worker/v1/worker_pb.js"; import { type FC } from "react"; import { fromProtoDate } from "../../../../../helpers.ts"; const dateFormatter = new Intl.DateTimeFormat(undefined, { dateStyle: "medium", }); export interface PaidLeavesProps { worker: Worker; loading?: boolean; } export const PaidLeaves: FC<PaidLeavesProps> = ({ worker, loading }) => { if (!worker.paidLeaveProvisions.length) { return ( <Flex asChild align="center" gap="1" my="4"> <Text color="gray"> <InfoCircledIcon /> <Text size="2">利用可能な年次有給休暇はありません</Text> </Text> </Flex> ); } return ( <Flex gap="2"> {worker.paidLeaveProvisions.map((provision, i) => { if (!provision.remainingDays && !provision.isHalvedDayRemaining) { return null; } return ( <Skeleton key={ provision.providedAt ? fromProtoDate(provision.providedAt).toISOString() : i } loading={loading} > <Card> <Box> <Text as="div" mb="1"> 残り <Text weight="bold"> {provision.remainingDays > 0 ? `${provision.remainingDays}日${provision.isHalvedDayRemaining ? "半" : ""}` : provision.isHalvedDayRemaining ? "半日" : "0日"} </Text> 利用可能 </Text> <Text as="div" size="2" color="gray"> {provision.providedAt && provision.expiresAt ? ( dateFormatter.formatRange( fromProtoDate(provision.providedAt), fromProtoDate(provision.expiresAt), ) ) : ( <> {provision.providedAt && dateFormatter.format(fromProtoDate(provision.providedAt))} {" ~ "} {provision.expiresAt && dateFormatter.format(fromProtoDate(provision.expiresAt))} </> )} </Text> </Box> </Card> </Skeleton> ); })} </Flex> ); };
-
-
-
@@ -0,0 +1,24 @@// 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 { DateSchema } from "@yamori/proto/yamori/type/v1/date_pb.js"; import { eachDayOfInterval } from "date-fns"; import { fromProtoDate, toProtoDate } from "../../../../../helpers.ts"; export function useDateCells( since: proto.MessageShape<typeof DateSchema>, until: proto.MessageShape<typeof DateSchema>, ): proto.MessageShape<typeof DateSchema>[] { return eachDayOfInterval( { start: fromProtoDate(since), end: fromProtoDate(until), }, { in: tz("Asia/Tokyo") }, ).map((datetime) => { return toProtoDate(datetime); }); }
-
-
-
@@ -0,0 +1,88 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import * as proto from "@bufbuild/protobuf"; import type { Decorator, Meta, StoryObj } from "@storybook/react"; import { WorkspaceSchema } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import { withInmemoryRouter } from "../../../../../.storybook/decorators/withInmemoryRouter.tsx"; import { withMockedBackend } from "../../../../../.storybook/decorators/withMockedBackend.tsx"; import { On } from "../../../../contexts/Router.tsx"; import * as workerService from "../../../../mocks/yamori/worker/v1/worker_service.ts"; import { Page, pattern } from "./page.tsx"; function withRoute<Args>(): Decorator<Args> { return (Story) => ( <On pattern={pattern}> <Story /> </On> ); } export default { component: Page, parameters: { layout: "fullscreen", }, args: { workspace: proto.create(WorkspaceSchema, { id: { value: "ws-foo" }, displayName: "Foo Workspace", }), }, decorators: [withMockedBackend([workerService.Get()])], } satisfies Meta<typeof Page>; type Story = StoryObj<typeof Page>; export const Success: Story = { decorators: [ withRoute(), withInmemoryRouter({ initialURL: "/ws-foo/workers/wr-alice" }), ], }; export const NotFound: Story = { decorators: [ withRoute(), withInmemoryRouter({ initialURL: "/ws-foo/workers/wr-alice/invalid" }), ], }; export const Loading: Story = { decorators: [ withRoute(), withInmemoryRouter({ initialURL: "ws-foo/workers/wr-alice" }), withMockedBackend([workerService.Get({ delayMs: 5_000 })]), ], }; export const NonExistentWorker: Story = { decorators: [ withRoute(), withInmemoryRouter({ initialURL: "ws-foo/workers/wr-alice" }), withMockedBackend([ workerService.Get({ failureRate: 1, error: { case: "notFound", value: {}, }, }), ]), ], }; export const LoadError: Story = { decorators: [ withRoute(), withInmemoryRouter({ initialURL: "ws-foo/workers/wr-alice" }), withMockedBackend([ workerService.Get({ failureRate: 1, }), ]), ], };
-
-
-
@@ -0,0 +1,149 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import "../../../../polyfill.ts"; import { isMessage } from "@bufbuild/protobuf"; import { Button, Code, Flex, Spinner, Text } from "@radix-ui/themes"; import { WorkerSchema } from "@yamori/proto/yamori/worker/v1/worker_pb.js"; import { type Workspace } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import { GetRequestSchema } from "@yamori/proto/yamori/worker/v1/get_request_pb.js"; import { GetResponseSchema } from "@yamori/proto/yamori/worker/v1/get_response_pb.js"; import { NotFoundSchema } from "@yamori/proto/yamori/error/v1/not_found_pb.js"; import { type FC } from "react"; import * as Empty from "../../../../components/Empty.ts"; import { ManagedErrorCallout } from "../../../../components/ErrorCallout.ts"; import { Select, useURLPatternResult } from "../../../../contexts/Router.tsx"; import { useMethodQuery } from "../../../../contexts/Service.tsx"; import { IllegalMessageError } from "../../../../errors/IllegalMessageError.ts"; import { Layout } from "../../Layout.tsx"; import * as root from "./Dashboard.tsx"; export interface PageProps { workspace: Workspace; } export const Page: FC<PageProps> = ({ workspace }) => { const { pathname } = useURLPatternResult(); const workerIdRaw = pathname.groups.worker!; const query = useMethodQuery({ service: "yamori.worker.v1.WorkerService", method: "Get", request: { schema: GetRequestSchema, data: { workspaceId: workspace.id, workerId: { value: workerIdRaw }, readMask: { fields: [ WorkerSchema.field.id.number, WorkerSchema.field.displayName.number, WorkerSchema.field.writeWorkRecordKey.number, WorkerSchema.field.providePaidLeaveKey.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; } }, }); if (query.isError) { if (isMessage(query.error, NotFoundSchema)) { return ( <Layout title="労働者取得失敗" workspace={workspace}> <Empty.Root> <Empty.Title>労働者が見つかりません</Empty.Title> <Empty.Description> ID が <Code>{workerIdRaw}</Code> の労働者が見つかりませんでした。 対象の労働者が既に削除されたか、打ち間違いや文字削除などにより ID が不完全の可能性があります。 </Empty.Description> <Empty.Actions> <Button asChild size="3"> <a href={`/${workspace.id?.value}/workers`}>労働者一覧へ</a> </Button> </Empty.Actions> </Empty.Root> </Layout> ); } return ( <Layout title="労働者取得失敗" workspace={workspace}> <Empty.Root> <Empty.Title>労働者の取得に失敗</Empty.Title> <Empty.Description> 労働者を読み込もうとしましたが、データ取得に失敗しました。 </Empty.Description> <ManagedErrorCallout title="取得失敗" error={query.error} /> <Empty.Actions> <Button size="3" onClick={() => void query.refetch()}> 再取得 </Button> <Button asChild size="3" variant="outline"> <a href={`/${workspace.id?.value}/workers`}>労働者一覧へ</a> </Button> </Empty.Actions> </Empty.Root> </Layout> ); } if (!query.data) { return ( <Layout title="労働者取得中..." workspace={workspace}> <Flex position="absolute" inset="0" align="center" justify="center" gap="2"> <Spinner /> <Text size="2" color="gray"> 労働者を読込中... </Text> </Flex> </Layout> ); } const worker = query.data; return ( <Select routes={[ { pattern: root.pattern, children: <root.Page workspace={workspace} worker={worker} />, }, ]} fallback={ <Layout title="404" workspace={workspace} worker={worker}> <Empty.Root> <Empty.Title>ページが見つかりません</Empty.Title> <Empty.Description>指定された URL にページはありません。</Empty.Description> <Empty.Actions> <Button asChild size="3"> <a href={root.href({ workspace, worker })}>労働者詳細へ</a> </Button> </Empty.Actions> </Empty.Root> </Layout> } /> ); }; export const pattern = new URLPattern({ pathname: "/:workspace/workers/:worker(wr-[^\\/]+)/:flag*", });
-
-
-
@@ -4,16 +4,19 @@import { create } from "@bufbuild/protobuf"; import { Flex, Separator } from "@radix-ui/themes"; import { type Worker, WorkerSchema } from "@yamori/proto/yamori/worker/v1/worker_pb.js"; import { type Workspace } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import { type FC, Fragment } from "react"; import { Row } from "./Row.tsx"; export interface LoadingListProps { workspace: Workspace; workers?: undefined; loading: true; } export interface LoadedListProps { workspace: Workspace; workers: readonly Worker[]; loading?: false; }
-
@@ -32,20 +35,20 @@ displayName: "あ いいいいい",}), ]; export const List: FC<ListProps> = ({ workers, loading }) => { export const List: FC<ListProps> = ({ workspace, workers, loading }) => { return ( <Flex direction="column" gap="3"> {loading ? SKELETON_WORKERS.map((worker, i) => ( <Fragment key={i}> {i > 0 && <Separator size="4" />} <Row loading worker={worker} /> <Row workspace={workspace} loading worker={worker} /> </Fragment> )) : workers.map((worker, i) => ( <Fragment key={worker.id?.value}> {i > 0 && <Separator size="4" />} <Row worker={worker} /> <Row workspace={workspace} worker={worker} /> </Fragment> ))} </Flex>
-
-
-
@@ -2,26 +2,33 @@ // SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com>// SPDX-License-Identifier: AGPL-3.0-only import { PersonIcon } from "@radix-ui/react-icons"; import { Avatar, Flex, Text, Skeleton } from "@radix-ui/themes"; import { Avatar, Flex, Link, Skeleton } from "@radix-ui/themes"; import { type Worker } from "@yamori/proto/yamori/worker/v1/worker_pb.js"; import { type Workspace } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import { type FC } from "react"; import * as workerDashboard from "./:id/Dashboard.tsx"; export interface RowProps { workspace: Workspace; worker: Worker; loading?: boolean; } export const Row: FC<RowProps> = ({ worker, loading = false }) => { export const Row: FC<RowProps> = ({ workspace, worker, loading = false }) => { return ( <Flex align="center" gap="2"> <Skeleton loading={loading}> <Avatar fallback={worker.displayName[0] || <PersonIcon />} /> </Skeleton> <Skeleton loading={loading}> <Text color={worker.displayName ? undefined : "gray"}> <Link href={workerDashboard.href({ workspace, worker })} color={worker.displayName ? undefined : "gray"} > {worker.displayName || "名称未設定"} </Text> </Link> </Skeleton> </Flex> );
-
-
-
@@ -91,7 +91,11 @@ </Button>} /> )} {!workers.data ? <List loading /> : <List workers={workers.data.workers} />} {!workers.data ? ( <List workspace={workspace} loading /> ) : ( <List workspace={workspace} workers={workers.data.workers} /> )} </Flex> ); };
-
-
-
@@ -23,6 +23,7 @@ workspaceService.Get(),workspaceService.List(), workspaceService.Create(), workerService.List(), workerService.Get(), ]), ], } satisfies Meta<typeof Page>;
-
-
-
@@ -4,7 +4,7 @@ "compilerOptions": {"moduleResolution": "Bundler", "moduleDetection": "force", "noEmit": true, "lib": ["ES2020", "DOM", "DOM.iterable"], "lib": ["ES2022", "DOM", "DOM.iterable"], "types": ["vite/client"], "jsx": "react-jsx" },
-
-
-
@@ -17,6 +17,8 @@ cssFileName: "styles",}, rollupOptions: { external: [ "date-fns", /^@date-fns\//, "react", "react-dom", /^react\//,
-