Changes
8 changed files (+780/-80)
-
-
@@ -0,0 +1,70 @@// SPDX-FileCopyrightText: 2025 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 { RecordKindSchema } from "@yamori/proto/yamori/work_record/v1/record_kind_pb.js"; import { type AbbreviationsSchema } from "@yamori/proto/yamori/workspace/v1/abbreviations_pb.js"; import { type FC } from "react"; const ResponsiveBadge: FC<BadgeProps> = ({ children, ...rest }) => { return ( <Badge {...rest}> <Text truncate>{children}</Text> </Badge> ); }; export interface RecordKindBadgeProps { abbreviations?: proto.MessageShape<typeof AbbreviationsSchema>; record: proto.MessageShape<typeof RecordKindSchema>; } export const RecordKindBadge: FC<RecordKindBadgeProps & BadgeProps> = ({ abbreviations, record, ...rest }) => { switch (record.kind.case) { case "worked": return ( <ResponsiveBadge {...rest} color="blue"> {abbreviations?.worked || "出勤"} </ResponsiveBadge> ); case "dayOff": return ( <ResponsiveBadge {...rest} color="red"> {abbreviations?.dayoff || "休日"} </ResponsiveBadge> ); case "skipped": return ( <ResponsiveBadge {...rest} color="amber"> {abbreviations?.skipWork || "欠勤"} </ResponsiveBadge> ); case "paidLeave": return ( <ResponsiveBadge {...rest} color="green"> {abbreviations?.paidLeave || "有給休暇"} </ResponsiveBadge> ); case "workspaceDefinedLeave": return ( <ResponsiveBadge {...rest} color={ record.kind.value.currentRevision?.snapshot?.isWorkerDeemedToBeWorked ? "green" : "amber" } > {record.kind.value.abbreviationName || record.kind.value.displayName || "休暇"} </ResponsiveBadge> ); default: return null; } };
-
-
-
@@ -2,73 +2,12 @@ // 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 { type BadgeProps } from "@radix-ui/themes"; import { WorkRecordSchema } from "@yamori/proto/yamori/work_record/v1/work_record_pb.js"; import { RecordKindSchema } from "@yamori/proto/yamori/work_record/v1/record_kind_pb.js"; import { type AbbreviationsSchema } from "@yamori/proto/yamori/workspace/v1/abbreviations_pb.js"; import { type FC } from "react"; const ResponsiveBadge: FC<BadgeProps> = ({ children, ...rest }) => { return ( <Badge {...rest}> <Text truncate>{children}</Text> </Badge> ); }; interface RecordKindBadgeProps { abbreviations?: proto.MessageShape<typeof AbbreviationsSchema>; record: proto.MessageShape<typeof RecordKindSchema>; } const RecordKindBadge: FC<RecordKindBadgeProps & BadgeProps> = ({ abbreviations, record, ...rest }) => { switch (record.kind.case) { case "worked": return ( <ResponsiveBadge {...rest} color="blue"> {abbreviations?.worked || "出勤"} </ResponsiveBadge> ); case "dayOff": return ( <ResponsiveBadge {...rest} color="red"> {abbreviations?.dayoff || "休日"} </ResponsiveBadge> ); case "skipped": return ( <ResponsiveBadge {...rest} color="amber"> {abbreviations?.skipWork || "欠勤"} </ResponsiveBadge> ); case "paidLeave": return ( <ResponsiveBadge {...rest} color="green"> {abbreviations?.paidLeave || "有給休暇"} </ResponsiveBadge> ); case "workspaceDefinedLeave": return ( <ResponsiveBadge {...rest} color={ record.kind.value.currentRevision?.snapshot?.isWorkerDeemedToBeWorked ? "green" : "amber" } > {record.kind.value.abbreviationName || record.kind.value.displayName || "休暇"} </ResponsiveBadge> ); default: return null; } }; import { RecordKindBadge } from "./RecordKindBadge.tsx"; export interface WorkRecordBadgesProps extends Pick<
-
-
-
@@ -0,0 +1,131 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import * as proto from "@bufbuild/protobuf"; import { Dialog } from "@radix-ui/themes"; import { WorkerSchema } from "@yamori/proto/yamori/worker/v1/worker_pb.js"; import { WorkspaceSchema } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import { WorkRecordSchema } from "@yamori/proto/yamori/work_record/v1/work_record_pb.js"; import type { Meta, StoryObj } from "@storybook/react"; import { RecordEditDialog } from "./RecordEditDialog.ts"; export default { component: RecordEditDialog, args: { workspace: proto.create(WorkspaceSchema, { id: { value: "ws-foo" }, abbreviations: { worked: "出勤", dayoff: "休日", skipWork: "欠勤", paidLeave: "年休", }, leaveDefinitions: [ { id: { value: "lv-foo" }, displayName: "産前産後休業", abbreviationName: "産休", currentRevision: { revisionId: { value: "lr-foo-foo" }, startAt: { year: 1947, month: 9, day: 1 }, snapshot: { isWorkerDeemedToBeWorked: true }, }, revisions: [ { revisionId: { value: "lr-foo-foo" }, startAt: { year: 1947, month: 9, day: 1 }, snapshot: { isWorkerDeemedToBeWorked: true }, }, ], }, { id: { value: "lv-baz" }, displayName: "リフレッシュ休暇", abbreviationName: "リ休", deletionKey: { key: new Uint8Array([]) }, revisions: [ { revisionId: { value: "lr-baz-foo" }, startAt: { year: 2010, month: 10, day: 1 }, snapshot: { isWorkerDeemedToBeWorked: true }, }, ], }, ], }), worker: proto.create(WorkerSchema, { writeWorkRecordKey: { key: new Uint8Array([0]), }, }), record: proto.create(WorkRecordSchema, { date: { year: 2025, month: 1, day: 1, }, kind: { case: "dayWhole", value: { kind: { case: "worked", value: {}, }, }, }, }), }, decorators: [ (Story) => ( <Dialog.Root open> <Story /> </Dialog.Root> ), ], } satisfies Meta<typeof RecordEditDialog>; type Story = StoryObj<typeof RecordEditDialog>; export const WorkedWholeDay: Story = {}; export const Readonly: Story = { args: { worker: proto.create(WorkerSchema), }, }; export const HalvedDay: Story = { args: { record: proto.create(WorkRecordSchema, { date: { year: 2025, month: 12, day: 10, }, kind: { case: "dayHalved", value: { am: { kind: { case: "worked", value: {}, }, }, pm: { kind: { case: "dayOff", value: {}, }, }, }, }, }), }, }; export const Empty: Story = { args: { record: proto.create(WorkRecordSchema), }, };
-
-
-
@@ -0,0 +1,5 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only export { RecordEditDialog } from "./RecordEditDialog/RecordEditDialog.tsx"; export type { RecordEditDialogProps } from "./RecordEditDialog/RecordEditDialog.tsx";
-
-
-
@@ -0,0 +1,186 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import * as proto from "@bufbuild/protobuf"; import { DateSchema } from "@yamori/proto/yamori/type/v1/date_pb.js"; import { RecordKindSchema } from "@yamori/proto/yamori/work_record/v1/record_kind_pb.js"; import { RecordKindWriteInputSchema } from "@yamori/proto/yamori/work_record/v1/record_kind_write_input_pb.js"; import { WorkRecordSchema } from "@yamori/proto/yamori/work_record/v1/work_record_pb.js"; import { WorkRecordBatchWriteInputSchema } from "@yamori/proto/yamori/work_record/v1/work_record_batch_write_input_pb.js"; export type RecordType = | null | "worked" | "skipped" | "day_off" | "paid_leave" | `lv-${string}`; export function isRecordType(x: unknown): x is RecordType { switch (x) { case null: case "worked": case "skipped": case "day_off": case "paid_leave": return true; } if (typeof x !== "string") { return false; } return x.startsWith("lv-"); } export interface RecordFields { record: RecordType; hourly_paid_leave: number; } export interface FormValue { kind: "day_whole" | "day_halved"; day_whole: RecordFields; am: RecordFields; pm: RecordFields; note: string; } function recordKindToRecordFields( kind?: proto.MessageShape<typeof RecordKindSchema>, ): RecordFields { switch (kind?.kind.case) { case "worked": return { record: "worked", hourly_paid_leave: kind.kind.value.hourlyPaidLeave?.hours ?? 0, }; case "skipped": return { record: "skipped", hourly_paid_leave: kind.kind.value.hourlyPaidLeave?.hours ?? 0, }; case "dayOff": return { record: "day_off", hourly_paid_leave: 0, }; case "paidLeave": return { record: "paid_leave", hourly_paid_leave: 0, }; case "workspaceDefinedLeave": return { record: kind.kind.value.id?.value.startsWith("lv-") ? (kind.kind.value.id.value as `lv-${string}`) : null, hourly_paid_leave: 0, }; default: return { record: null, hourly_paid_leave: 0, }; } } export function fromWorkRecord( record: proto.MessageShape<typeof WorkRecordSchema>, ): FormValue { if (record.kind.case === "dayHalved") { return { kind: "day_halved", day_whole: recordKindToRecordFields(), am: recordKindToRecordFields(record.kind.value.am), pm: recordKindToRecordFields(record.kind.value.pm), note: record.note, }; } return { kind: "day_whole", day_whole: recordKindToRecordFields(record.kind.value), am: recordKindToRecordFields(), pm: recordKindToRecordFields(), note: record.note, }; } function recordFieldsToRecordKindWriteInput( fields: RecordFields, ): proto.MessageShape<typeof RecordKindWriteInputSchema> { switch (fields.record) { case null: return proto.create(RecordKindWriteInputSchema); case "skipped": case "worked": return proto.create(RecordKindWriteInputSchema, { kind: { case: fields.record === "skipped" ? "skipped" : "worked", value: { hourlyPaidLeave: fields.hourly_paid_leave > 0 ? { hours: fields.hourly_paid_leave, } : undefined, }, }, }); case "day_off": return proto.create(RecordKindWriteInputSchema, { kind: { case: "dayOff", value: {}, }, }); case "paid_leave": return proto.create(RecordKindWriteInputSchema, { kind: { case: "paidLeave", value: {}, }, }); default: return proto.create(RecordKindWriteInputSchema, { kind: { case: "workspaceDefinedLeaveId", value: { value: fields.record, }, }, }); } } export function toWorkRecordBatchWriteInput( date: proto.MessageShape<typeof DateSchema>, values: FormValue, ): proto.MessageShape<typeof WorkRecordBatchWriteInputSchema> { if (values.kind === "day_whole") { return proto.create(WorkRecordBatchWriteInputSchema, { dates: [date], kind: { case: "dayWhole", value: recordFieldsToRecordKindWriteInput(values.day_whole), }, note: values.note, }); } return proto.create(WorkRecordBatchWriteInputSchema, { dates: [date], kind: { case: "dayHalved", value: { am: recordFieldsToRecordKindWriteInput(values.am), pm: recordFieldsToRecordKindWriteInput(values.pm), }, }, note: values.note, }); }
-
-
-
@@ -0,0 +1,188 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import * as proto from "@bufbuild/protobuf"; import { Button, Dialog, Flex, SegmentedControl, Text } from "@radix-ui/themes"; import { NotFoundSchema } from "@yamori/proto/yamori/error/v1/not_found_pb.js"; import { WorkerSchema } from "@yamori/proto/yamori/worker/v1/worker_pb.js"; import { WriteWorkRecordRequestSchema } from "@yamori/proto/yamori/worker/v1/write_work_record_request_pb.js"; import { WriteWorkRecordResponseSchema } from "@yamori/proto/yamori/worker/v1/write_work_record_response_pb.js"; import { type WorkspaceSchema } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import { WorkRecordSchema } from "@yamori/proto/yamori/work_record/v1/work_record_pb.js"; import { type FC, useMemo } from "react"; import { FormProvider, useForm, useWatch } from "react-hook-form"; import { useMethodMutation } from "../../../../contexts/Service.tsx"; import { useToast } from "../../../../contexts/Toast.tsx"; import { IllegalMessageError } from "../../../../errors/IllegalMessageError.ts"; import { fromProtoDate } from "../../../../helpers.ts"; import { type FormValue, fromWorkRecord, toWorkRecordBatchWriteInput, } from "./FormValue.ts"; import { RecordKindFields } from "./RecordKindFields.tsx"; const dateFormatter = new Intl.DateTimeFormat("ja", { dateStyle: "medium", }); export interface RecordEditDialogProps { worker: proto.MessageShape<typeof WorkerSchema>; record: proto.MessageShape<typeof WorkRecordSchema>; workspace: proto.MessageShape<typeof WorkspaceSchema>; onSuccessfullyRecordEdited?(): void; } export const RecordEditDialog: FC<RecordEditDialogProps> = ({ worker, record, workspace, onSuccessfullyRecordEdited, }) => { const isReadonly = !worker.writeWorkRecordKey; const date = record.date && fromProtoDate(record.date); const toast = useToast(); const write = useMethodMutation({ service: "yamori.worker.v1.WorkerService", method: "WriteWorkRecord", request: { schema: WriteWorkRecordRequestSchema, }, response: { schema: WriteWorkRecordResponseSchema, }, mapResponse(resp) { switch (resp.result.case) { case "ok": return resp.result.value; case undefined: throw new IllegalMessageError(resp); default: throw resp.result.value; } }, options: { onSuccess() { onSuccessfullyRecordEdited?.(); }, onError(error) { if ( proto.isMessage(error, NotFoundSchema) && error.typeName === "yamori.worker.v1.PaidLeaveProvision" ) { toast.open({ severity: "warn", title: "年次有給休暇の残り日数がありません", }); return; } toast.open({ severity: "danger", title: "勤怠記録の更新に失敗しました", }); }, }, }); const defaultValues = useMemo(() => fromWorkRecord(record), [record]); const form = useForm<FormValue>({ defaultValues, mode: "onBlur", disabled: isReadonly, }); const kind = useWatch({ control: form.control, name: "kind", }); if (!date) { return null; } return ( <Dialog.Content asChild> <form onSubmit={form.handleSubmit(async (values) => { if (!record.date) { return; } await write.mutateAsync({ workspaceId: workspace.id, workerId: worker.id, writeWorkRecordKey: worker.writeWorkRecordKey, workRecord: toWorkRecordBatchWriteInput(record.date, values), readMask: { fields: [], }, }); onSuccessfullyRecordEdited?.(); })} > <Dialog.Title>{dateFormatter.format(date)}</Dialog.Title> <Dialog.Description> {dateFormatter.format(date)} の勤怠履歴です。 </Dialog.Description> <Flex mt="3" justify="center" align="center"> <SegmentedControl.Root {...form.register("kind")} defaultValue={defaultValues.kind} onValueChange={(value) => { switch (value) { case "day_whole": case "day_halved": form.setValue("kind", value); return; } }} > <SegmentedControl.Item value="day_whole">全日</SegmentedControl.Item> <SegmentedControl.Item value="day_halved">半日</SegmentedControl.Item> </SegmentedControl.Root> </Flex> <Flex direction="column" gap="1" mt="2"> <FormProvider {...form}> {kind === "day_halved" ? ( <> <Text size="2" color="gray"> AM </Text> <RecordKindFields field="am" workspace={workspace} /> <Text mt="2" size="2" color="gray"> PM </Text> <RecordKindFields field="pm" workspace={workspace} /> </> ) : ( <RecordKindFields field="day_whole" workspace={workspace} /> )} </FormProvider> </Flex> <Flex mt="5" justify="end" align="center" gap="2"> {isReadonly ? ( <Dialog.Close> <Button>閉じる</Button> </Dialog.Close> ) : ( <> <Dialog.Close> <Button variant="outline">キャンセル</Button> </Dialog.Close> {!isReadonly && <Button>保存</Button>} </> )} </Flex> </form> </Dialog.Content> ); };
-
-
-
@@ -0,0 +1,131 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import * as proto from "@bufbuild/protobuf"; import { Select } from "@radix-ui/themes"; import { type WorkspaceSchema } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import { RecordKindSchema } from "@yamori/proto/yamori/work_record/v1/record_kind_pb.js"; import { type FC } from "react"; import { Controller, useFormContext } from "react-hook-form"; import * as FormField from "../../../../components/FormField.ts"; import { RecordKindBadge } from "../../../../components/RecordKindBadge.tsx"; import { type FormValue, type RecordType, isRecordType } from "./FormValue.ts"; export interface RecordKindFieldsProps { field: keyof Pick<FormValue, "am" | "pm" | "day_whole">; workspace?: proto.MessageShape<typeof WorkspaceSchema>; } export const RecordKindFields: FC<RecordKindFieldsProps> = ({ field, workspace }) => { const form = useFormContext<FormValue>(); return ( <> <FormField.Root> <FormField.Label>勤怠記録</FormField.Label> <Controller control={form.control} name={`${field}.record`} rules={{ required: { value: true, message: "必須です", }, }} render={({ field: { disabled, name, value, onChange, ...field }, fieldState, }) => { return ( <> <Select.Root disabled={disabled} name={name} value={value ?? undefined} onValueChange={(value) => { if (isRecordType(value)) { onChange(value); } }} > <Select.Trigger {...field} /> <Select.Content> <Select.Item value={"worked" satisfies RecordType}> <RecordKindBadge record={proto.create(RecordKindSchema, { kind: { case: "worked", value: {}, }, })} /> </Select.Item> <Select.Item value={"skipped" satisfies RecordType}> <RecordKindBadge record={proto.create(RecordKindSchema, { kind: { case: "skipped", value: {}, }, })} /> </Select.Item> <Select.Item value={"day_off" satisfies RecordType}> <RecordKindBadge record={proto.create(RecordKindSchema, { kind: { case: "dayOff", value: {}, }, })} /> </Select.Item> <Select.Item value={"paid_leave" satisfies RecordType}> <RecordKindBadge record={proto.create(RecordKindSchema, { kind: { case: "paidLeave", value: {}, }, })} /> </Select.Item> {workspace?.leaveDefinitions.map((leave) => { if (!leave.id) { return null; } return ( <Select.Item key={leave.id.value} value={leave.id.value}> <RecordKindBadge record={proto.create(RecordKindSchema, { kind: { case: "workspaceDefinedLeave", value: { ...leave, abbreviationName: "", }, }, })} /> </Select.Item> ); })} </Select.Content> </Select.Root> {fieldState.error?.message && ( <FormField.Description color="red"> {fieldState.error.message} </FormField.Description> )} </> ); }} /> </FormField.Root> </> ); };
-
-
-
@@ -10,6 +10,8 @@ import {Avatar, Box, Container, ContextMenu, Dialog, Flex, Grid, IconButton,
-
@@ -45,6 +47,7 @@ import * as workerDashboard from "../workers/:id/Dashboard.tsx";import { Layout } from "../Layout.tsx"; import { Menu } from "./Menu.tsx"; import { RecordEditDialog, type RecordEditDialogProps } from "./RecordEditDialog.ts"; import * as Spreadsheet from "./Spreadsheet.ts"; import { useActions } from "./useActions.ts"; import css from "./page.module.css";
-
@@ -110,7 +113,7 @@ end: toProtoDate(endOfMonth(d, { in: tz(TZ) })),} as const; }, [month]); const abbrs = useMethodQuery({ const wsDetails = useMethodQuery({ service: "yamori.workspace.v1.WorkspaceService", method: "Get", request: {
-
@@ -118,7 +121,11 @@ schema: GetRequestSchema,data: { workspaceId: workspace.id, readMask: { fields: [WorkspaceSchema.field.abbreviations.number], fields: [ WorkspaceSchema.field.abbreviations.number, WorkspaceSchema.field.leaveDefinitions.number, WorkspaceSchema.field.id.number, ], }, }, },
-
@@ -128,7 +135,7 @@ },mapResponse(resp) { switch (resp.result.case) { case "ok": return resp.result.value.abbreviations; return resp.result.value; case undefined: throw new IllegalMessageError(resp); default:
-
@@ -309,8 +316,20 @@return () => void document.removeEventListener("keyup", listener); }, [rows, columns]); const [editDialogPayload, setEditDialogPayload] = useState<Pick< RecordEditDialogProps, "worker" | "record" > | null>(null); return ( <> <Dialog.Root open={!!editDialogPayload} onOpenChange={(isOpen) => { if (!isOpen) { setEditDialogPayload(null); } }} > <Container px="2" py="4" size="2"> <Flex align="center" gap="1"> <IconButton
-
@@ -427,23 +446,43 @@ );const contents = record && ( <WorkRecordBadges abbreviations={abbrs.data} abbreviations={wsDetails.data?.abbreviations} workRecord={record} /> ); return ( <Spreadsheet.Cell key={+date} asChild column={dateIndex}> <Flex className={`${css.recordCell} ${css.writableCell}`} direction="column" gap="1" p="1" minWidth="0" > {contents} </Flex> </Spreadsheet.Cell> <ContextMenu.Root key={+date}> <ContextMenu.Trigger> <Spreadsheet.Cell asChild column={dateIndex}> <Flex className={`${css.recordCell} ${css.writableCell}`} direction="column" gap="1" p="1" minWidth="0" > {contents} </Flex> </Spreadsheet.Cell> </ContextMenu.Trigger> <ContextMenu.Content> <ContextMenu.Item onSelect={() => { setEditDialogPayload({ worker, record: record || proto.create(WorkRecordSchema, { date: d, }), }); }} > 詳細 </ContextMenu.Item> </ContextMenu.Content> </ContextMenu.Root> ); })} </Spreadsheet.Row>
-
@@ -453,7 +492,18 @@ </Grid></Spreadsheet.Root> </ScrollArea> </Box> </> {wsDetails.data && editDialogPayload && ( <RecordEditDialog workspace={wsDetails.data} {...editDialogPayload} onSuccessfullyRecordEdited={() => { query.refetch(); setEditDialogPayload(null); }} /> )} </Dialog.Root> ); };
-