Changes
12 changed files (+714/-91)
-
-
-
@@ -65,6 +65,7 @@ },"dependencies": { "@bufbuild/protobuf": "^2.2.2", "@date-fns/tz": "^1.2.0", "@radix-ui/react-checkbox": "^1.1.3", "@radix-ui/react-collapsible": "^1.1.2", "@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-toast": "^1.2.4",
-
-
-
@@ -0,0 +1,115 @@// 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 { WorkRecordSchema } from "@yamori/proto/yamori/work_record/v1/work_record_pb.js"; import { WorkRecordBadges } from "./WorkRecordBadges.tsx"; export default { component: WorkRecordBadges, args: { workRecord: proto.create(WorkRecordSchema), }, } satisfies Meta<typeof WorkRecordBadges>; type Story = StoryObj<typeof WorkRecordBadges>; export const Unknown: Story = {}; export const DayOff: Story = { args: { workRecord: proto.create(WorkRecordSchema, { record: { case: "dayOff", value: {}, }, }), }, }; export const WorkingDay: Story = { args: { workRecord: proto.create(WorkRecordSchema, { record: { case: "workingDay", value: { hasWorkerWorked: true, timeOffs: [ { kind: { case: "halvedPaidLeave", value: {}, }, }, { kind: { case: "hourlyPaidLeave", value: { hours: 3, }, }, }, {}, ], }, }, }), }, }; export const SkippedWork: Story = { args: { workRecord: proto.create(WorkRecordSchema, { record: { case: "workingDay", value: { hasWorkerWorked: false, }, }, }), }, }; export const PaidLeave: Story = { args: { workRecord: proto.create(WorkRecordSchema, { record: { case: "paidLeave", value: {}, }, }), }, }; export const SpecialLeave: Story = { args: { workRecord: proto.create(WorkRecordSchema, { record: { case: "specialLeave", value: { displayName: "リフレッシュ休暇", }, }, }), }, }; export const LegalLeave: Story = { args: { workRecord: proto.create(WorkRecordSchema, { record: { case: "legalLeave", value: { displayName: "産前産後休業", currentRevision: { snapshot: { isWorkerDeemedToBeWorked: true, }, }, }, }, }), }, };
-
-
-
@@ -0,0 +1,100 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import * as proto from "@bufbuild/protobuf"; import { Badge, type BadgeProps, Text } from "@radix-ui/themes"; import { WorkRecordSchema } from "@yamori/proto/yamori/work_record/v1/work_record_pb.js"; import { type FC } from "react"; const ResponsiveBadge: FC<BadgeProps> = ({ children, ...rest }) => { return ( <Badge {...rest}> <Text truncate>{children}</Text> </Badge> ); }; export interface WorkRecordBadgesProps extends Pick< BadgeProps, "size" | "m" | `m${"t" | "b" | "r" | "l" | "x" | "y"}` | "style" | "className" > { workRecord: proto.MessageShape<typeof WorkRecordSchema>; } export const WorkRecordBadges: FC<WorkRecordBadgesProps> = ({ workRecord, ...rest }) => { switch (workRecord.record.case) { case "workingDay": { const { hasWorkerWorked, timeOffs } = workRecord.record.value; return ( <> {hasWorkerWorked ? ( <ResponsiveBadge {...rest} color="blue"> 出勤 </ResponsiveBadge> ) : ( <ResponsiveBadge {...rest} color="amber"> 欠勤 </ResponsiveBadge> )} {timeOffs.map((timeOff, i) => { switch (timeOff.kind.case) { case "halvedPaidLeave": return ( <ResponsiveBadge key={i} {...rest} color="green"> 半休 </ResponsiveBadge> ); case "hourlyPaidLeave": return ( <ResponsiveBadge key={i} {...rest} color="green"> 時間単位年休 </ResponsiveBadge> ); default: return ( <ResponsiveBadge key={i} {...rest} color="gray"> 不明なレコード </ResponsiveBadge> ); } })} </> ); } case "dayOff": return ( <ResponsiveBadge {...rest} color="red"> 休日 </ResponsiveBadge> ); case "paidLeave": return ( <ResponsiveBadge {...rest} color="green"> 有給休暇 </ResponsiveBadge> ); case "specialLeave": case "legalLeave": return ( <ResponsiveBadge {...rest} color={ workRecord.record.value.currentRevision?.snapshot?.isWorkerDeemedToBeWorked ? "green" : "amber" } > {workRecord.record.value.displayName || (workRecord.record.case === "specialLeave" ? "特別休暇" : "法定休暇")} </ResponsiveBadge> ); default: return ( <ResponsiveBadge {...rest} color="gray"> 不明なデータ </ResponsiveBadge> ); } };
-
-
-
@@ -17,6 +17,7 @@export const URLContext = createContext<URL>(new URL(location.href)); export interface Navigation { replace(url: string | URL): void; push(url: string | URL): void; forward(): void;
-
@@ -25,6 +26,9 @@ }const nativeNavigation: Navigation = { push(url) { location.href = url instanceof URL ? url.href : url; }, replace(url) { location.href = url instanceof URL ? url.href : url; }, forward() {
-
@@ -53,6 +57,10 @@ const navigation = useMemo<Navigation>(() => {return { push(url) { history.pushState({}, "", url); setURL(new URL(url, location.href)); }, replace(url) { history.replaceState({}, "", url); setURL(new URL(url, location.href)); }, back() {
-
@@ -167,6 +175,19 @@ return {current: next, backwards: [current, ...backwards], forwards: [], }; }); }, replace(url) { setHistory(({ current, backwards, forwards }) => { const next = new URL(url, current); routeChangeCallback.current?.(next); return { current: next, backwards, forwards, }; }); },
-
-
-
@@ -35,12 +35,73 @@ id: {value: "wr-foo", }, displayName: "日本 太郎", workRecords: [ { date: toProtoDate(subDays(Date.now(), 1)), record: { case: "paidLeave", value: {}, }, }, ], }, { id: { value: "wr-bar", }, displayName: "行政 花子", workRecords: [ { date: toProtoDate(subDays(Date.now(), 2)), record: { case: "workingDay", value: { hasWorkerWorked: true, timeOffs: [ { kind: { case: "halvedPaidLeave", value: {}, }, }, { kind: { case: "hourlyPaidLeave", value: { hours: 3, }, }, }, ], }, }, }, { date: toProtoDate(subDays(Date.now(), 3)), record: { case: "workingDay", value: { hasWorkerWorked: true, }, }, }, { date: toProtoDate(subDays(Date.now(), 4)), record: { case: "workingDay", value: { hasWorkerWorked: false, }, }, }, { date: toProtoDate(subDays(Date.now(), 5)), record: { case: "dayOff", value: {}, }, }, ], }, { id: {
-
-
-
@@ -23,6 +23,7 @@ import * as NavigationMenu from "../../components/NavigationMenu.ts";import { URLContext } from "../../contexts/Router.tsx"; import { useViewTransition } from "../../hooks/useViewTransition.ts"; import * as calendar from "./calendar/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";
-
@@ -118,6 +119,11 @@ <Box asChild flexGrow="1" flexShrink="1"><ScrollArea> <NavigationMenu.Root> <NavigationMenu.Group title="労働者管理"> <NavigationMenu.Item current={calendar.pattern.test(url)}> <a href={calendar.href({ workspace })}> <calendar.Title /> </a> </NavigationMenu.Item> <NavigationMenu.Item current={workers.pattern.test(url)}> <a href={`/${workspace.id?.value}/workers`}>労働者一覧</a> </NavigationMenu.Item>
-
-
-
@@ -0,0 +1,17 @@/* * SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> * SPDX-License-Identifier: AGPL-3.0-only */ .rowHeaderCell { border-bottom: 1px solid var(--gray-a3); } .columnHeaderCell { border-right: 1px solid var(--gray-a3); } .recordCell { border-bottom: 1px solid var(--gray-a2); border-right: 1px solid var(--gray-a2); }
-
-
-
@@ -0,0 +1,42 @@// 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 { 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/calendar" }), withMockedBackend([List()]), ], } satisfies Meta<typeof Page>; type Story = StoryObj<typeof Page>; export const Success: Story = {}; export const MonthInURL: Story = { decorators: [withInmemoryRouter({ initialURL: "/ws-foo/calendar?month=2020-1" })], }; export const InvalidMonth: Story = { decorators: [withInmemoryRouter({ initialURL: "/ws-foo/calendar?month=2020" })], };
-
-
-
@@ -0,0 +1,337 @@// 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 { tz } from "@date-fns/tz"; import { CaretLeftIcon, CaretRightIcon, PersonIcon } from "@radix-ui/react-icons"; import { Avatar, Box, Flex, Grid, IconButton, ScrollArea, Section, Text, VisuallyHidden, } from "@radix-ui/themes"; import { DateSchema } from "@yamori/proto/yamori/type/v1/date_pb.js"; import type { WorkspaceSchema } from "@yamori/proto/yamori/workspace/v1/workspace_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 { WorkerSchema } from "@yamori/proto/yamori/worker/v1/worker_pb.js"; 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 { addMonths, subMonths, startOfMonth, endOfMonth, eachDayOfInterval, } from "date-fns"; import { type FC, Fragment, use, useEffect, useMemo, useRef } from "react"; import { WorkRecordBadges } from "../../../components/WorkRecordBadges.tsx"; import { NavigationContext, URLContext } from "../../../contexts/Router.tsx"; import { useMethodQuery } from "../../../contexts/Service.tsx"; import { IllegalMessageError } from "../../../errors/IllegalMessageError.ts"; import { fromProtoDate, toProtoDate, isSameDate } from "../../../helpers.ts"; import { Layout } from "../Layout.tsx"; import css from "./page.module.css"; export const Title: FC = () => "カレンダー"; function addMonthToSearchParams( month: proto.MessageShape<typeof DateSchema>, base?: URLSearchParams, ): URLSearchParams { const params = new URLSearchParams(base); params.set("month", `${month.year}-${month.month}`); return params; } export interface HrefInput { workspace: proto.MessageShape<typeof WorkspaceSchema>; month?: proto.MessageShape<typeof DateSchema>; } export function href({ workspace, month }: HrefInput): string { if (!workspace.id) { return "/"; } const base = `/${workspace.id.value}/calendar`; if (!month) { return base; } return base + "?" + addMonthToSearchParams(month).toString(); } export const pattern = new URLPattern({ pathname: "/:workspace/calendar", }); const leaveMask = proto.create(LeaveReadMaskSchema, { fields: [ LeaveSchema.field.id.number, LeaveSchema.field.displayName.number, LeaveSchema.field.currentRevision.number, ], }); // TODO: どこか共通のファイルで定義する const TZ = "Asia/Tokyo"; const weekdayFormatter = new Intl.DateTimeFormat(undefined, { weekday: "short", }); interface BodyProps { workspaceID: proto.MessageShape<typeof WorkspaceSchema>["id"]; month: proto.MessageShape<typeof DateSchema>; } const Body: FC<BodyProps> = ({ workspaceID, month }) => { const { start, end } = useMemo(() => { const d = fromProtoDate(month); return { start: toProtoDate(startOfMonth(d, { in: tz(TZ) })), end: toProtoDate(endOfMonth(d, { in: tz(TZ) })), } as const; }, [month]); const query = useMethodQuery({ service: "yamori.worker.v1.WorkerService", method: "List", request: { schema: ListRequestSchema, data: { workspaceId: workspaceID, workRecordFilter: { since: start, until: end, }, paidLeaveProvisionFilter: { providedAtUntil: end, expiresAtSince: start, }, readMask: { fields: [ WorkerSchema.field.id.number, WorkerSchema.field.displayName.number, WorkerSchema.field.writeWorkRecordKey.number, WorkerSchema.field.providePaidLeaveKey.number, WorkerSchema.field.workRecords.number, ], workRecordsMask: { legalLeaveReadMask: leaveMask, specialLeaveReadMask: leaveMask, }, }, }, }, 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 dates = useMemo(() => { return eachDayOfInterval( { start: fromProtoDate(start), end: fromProtoDate(end), }, { in: tz(TZ), }, ); }, [start, end]); const scrollAreaRef = useRef<HTMLDivElement>(null); useEffect(() => { if (!scrollAreaRef.current) { return; } scrollAreaRef.current.scrollLeft = 0; }, [month]); return ( <ScrollArea ref={scrollAreaRef} scrollbars="horizontal" size="2"> <Grid columns={`max-content repeat(${dates.length}, 5rem)`} pb="4"> <Box className={`${css.rowHeaderCell} ${css.columnHeaderCell}`} position="sticky" left="0" top="0" style={{ backgroundColor: "var(--color-background)", zIndex: 50 }} /> {dates.map((d) => ( <Flex key={+d} className={css.rowHeaderCell} position="sticky" top="0" align="baseline" justify="center" gap="1" py="3" px="4" style={{ zIndex: 10 }} > <Text size="3" weight="bold"> {d.getDate()} </Text> <Text size="1" weight="bold" color={d.getDay() === 0 ? "red" : d.getDay() === 6 ? "blue" : "gray"} > {weekdayFormatter.format(d)} </Text> </Flex> ))} {query.data?.map((worker) => { return ( <Fragment key={worker.id?.value}> <Flex className={css.columnHeaderCell} position="sticky" left="0" style={{ backgroundColor: "var(--color-background)", zIndex: 30 }} align="center" gap="2" px="2" py="4" > <Avatar size="2" fallback={worker.displayName[0] || <PersonIcon />} /> <Text>{worker.displayName}</Text> </Flex> {dates.map((date) => { const d = toProtoDate(date); const record = worker.workRecords.find( (record) => record.date && isSameDate(record.date, d), ); return ( <Flex key={+date} className={css.recordCell} direction="column" gap="1" p="1" minWidth="0" > {record && <WorkRecordBadges workRecord={record} />} </Flex> ); })} </Fragment> ); })} </Grid> </ScrollArea> ); }; 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 month = useMemo<proto.MessageShape<typeof DateSchema>>(() => { const param = searchParams.get("month"); if (!param) { return toProtoDate(Date.now()); } const tokens = param.split("-").map((s) => parseInt(s, 10)); if (tokens.length !== 2 || tokens.some((t) => !Number.isFinite(t))) { return toProtoDate(Date.now()); } return proto.create(DateSchema, { year: tokens[0], month: tokens[1], day: 1, }); }, [searchParams]); // ブラウザのクエリパラメータと month が違う場合は同期させる useEffect(() => { const next = addMonthToSearchParams(month, searchParams); if (next.toString() === searchParams.toString()) { return; } navigation.replace(href({ workspace, month })); }, [searchParams, month]); return ( <Layout workspace={workspace} title={<Title />}> <Flex align="center" gap="1"> <IconButton variant="soft" size="2" onClick={() => { navigation.replace( href({ workspace, month: toProtoDate(subMonths(fromProtoDate(month), 1)), }), ); }} > <CaretLeftIcon /> <VisuallyHidden>前の月へ</VisuallyHidden> </IconButton> <Text weight="bold" size="2" align="center" style={{ minWidth: "6rem" }}> {month.year}年{month.month}月 </Text> <IconButton variant="soft" size="2" onClick={() => { navigation.replace( href({ workspace, month: toProtoDate(addMonths(fromProtoDate(month), 1)), }), ); }} > <CaretRightIcon /> <VisuallyHidden>次の月へ</VisuallyHidden> </IconButton> </Flex> <Section size="2" maxWidth="100%" minWidth="0"> <Body workspaceID={workspace.id} month={month} /> </Section> </Layout> ); };
-
-
-
@@ -17,6 +17,7 @@ import { Select, useURLPatternResult } from "../../contexts/Router.tsx";import { useMethodQuery } from "../../contexts/Service.tsx"; import { IllegalMessageError } from "../../errors/IllegalMessageError.ts"; import * as calendar from "./calendar/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";
-
@@ -133,6 +134,10 @@ },{ pattern: leaveDefinitionsNew.pattern, children: <leaveDefinitionsNew.Page workspace={workspace} />, }, { pattern: calendar.pattern, children: <calendar.Page workspace={workspace} />, }, { pattern: workerSubRoute.pattern,
-
-
-
@@ -2,100 +2,12 @@ // 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 { 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> ); } }; import { WorkRecordBadges } from "../../../../../components/WorkRecordBadges.tsx"; export interface DatesViewerCellProps extends Omit<FlexProps, "as" | "asChild" | "direction"> {
-
@@ -123,7 +35,13 @@ </Text><Text size="2" weight="bold"> {date.day} </Text> <WorkRecordRenderer workRecord={workRecord} /> {workRecord?.record.case ? ( <WorkRecordBadges workRecord={workRecord} /> ) : ( <Text truncate size="1" color="gray"> 未入力 </Text> )} </Flex> ); };
-