Changes
3 changed files (+345/-367)
-
-
@@ -2,59 +2,27 @@ // SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com>// SPDX-License-Identifier: AGPL-3.0-only import * as proto from "@bufbuild/protobuf"; import { Button, type ButtonProps, Dialog, DropdownMenu, Flex } from "@radix-ui/themes"; 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 { Button, type ButtonProps, DropdownMenu } from "@radix-ui/themes"; import { LeaveSchema } from "@yamori/proto/yamori/work_record/v1/leave_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 { NotFoundSchema } from "@yamori/proto/yamori/error/v1/not_found_pb.js"; import { type DateSchema } from "@yamori/proto/yamori/type/v1/date_pb.js"; import { type WorkerSchema } from "@yamori/proto/yamori/worker/v1/worker_pb.js"; import { WorkspaceSchema } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import { isBefore } from "date-fns"; import { useMemo, useState, type FC } from "react"; import { type FC } from "react"; import { useMethodMutation, useMethodQuery } from "../../../contexts/Service.tsx"; import { useToast } from "../../../contexts/Toast.tsx"; import { useMethodQuery } from "../../../contexts/Service.tsx"; import { IllegalMessageError } from "../../../errors/IllegalMessageError.ts"; import { fromProtoDate } from "../../../helpers.ts"; const dateFormatter = new Intl.DateTimeFormat(navigator.language, { dateStyle: "medium", }); class CannotApplyLeaveToDates { constructor( public readonly leave: proto.MessageShape<typeof LeaveSchema>, public readonly availableSince: Date, public readonly dates: readonly Date[], ) {} } export interface ActionTarget { worker: proto.MessageShape<typeof WorkerSchema>; dates: proto.MessageShape<typeof DateSchema>[]; } import { type Actions } from "./useActions.ts"; export interface MenuProps extends Pick<ButtonProps, "size" | "variant" | "color" | "className" | "style"> { actions: Actions; workspaceID: proto.MessageShape<typeof WorkspaceSchema>["id"]; targets: readonly ActionTarget[]; onWorkRecordMutated?(): void; disabled?: boolean; } export const Menu: FC<MenuProps> = ({ targets, workspaceID, onWorkRecordMutated, ...rest }) => { const toast = useToast(); export const Menu: FC<MenuProps> = ({ actions, workspaceID, disabled, ...rest }) => { const leaveDefinitions = useMethodQuery({ service: "yamori.workspace.v1.WorkspaceService", method: "Get",
-
@@ -89,300 +57,67 @@ }}, }); const isWorkRecordWritable = targets.length > 0 && targets.every((target) => target.worker.writeWorkRecordKey); const writeRecord = useMethodMutation({ service: "yamori.worker.v1.WorkerService", method: "WriteWorkRecord", request: { schema: WriteWorkRecordRequestSchema, }, response: { schema: WriteWorkRecordResponseSchema, }, mapResponse(resp) { if (resp.result.case === "ok") { return resp.result.value.workRecords; } if (typeof resp.result.case === "undefined") { throw new IllegalMessageError(resp); } throw resp.result.value; }, options: { 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 dates = useMemo(() => { return targets .map(({ dates }) => dates) .flat() .map((d) => fromProtoDate(d)); }, [targets]); const [canceledDialog, setCanceledDialog] = useState<CannotApplyLeaveToDates | null>( null, ); return ( <Dialog.Root open={!!canceledDialog} onOpenChange={(open) => { if (!open) { setCanceledDialog(null); } }} > <DropdownMenu.Root> <DropdownMenu.Trigger> <Button {...rest} loading={writeRecord.isPending}> 操作 <DropdownMenu.TriggerIcon /> </Button> </DropdownMenu.Trigger> <DropdownMenu.Content> <DropdownMenu.Label>記録書き込み</DropdownMenu.Label> <DropdownMenu.Item disabled={!isWorkRecordWritable} onSelect={async () => { try { for (const target of targets) { await writeRecord.mutateAsync({ workspaceId: workspaceID, workerId: target.worker.id, writeWorkRecordKey: target.worker.writeWorkRecordKey, workRecord: { dates: target.dates, record: { case: "workingDay", value: { hasWorkerWorked: true, }, }, }, }); <DropdownMenu.Root> <DropdownMenu.Trigger> <Button {...rest} loading={actions.isPending}> 操作 <DropdownMenu.TriggerIcon /> </Button> </DropdownMenu.Trigger> <DropdownMenu.Content> <DropdownMenu.Label>記録書き込み</DropdownMenu.Label> <DropdownMenu.Item disabled={disabled} onSelect={() => void actions.markAsWorked()} > 出勤 </DropdownMenu.Item> <DropdownMenu.Item disabled={disabled} onSelect={() => void actions.markAsDayOff()} > 休日 </DropdownMenu.Item> <DropdownMenu.Item disabled={disabled} onSelect={() => void actions.markAsAbsent()} > 欠勤 </DropdownMenu.Item> <DropdownMenu.Item disabled={disabled} onSelect={() => void actions.markAsPaidLeave()} > 年次有給休暇 </DropdownMenu.Item> <DropdownMenu.Sub> <DropdownMenu.SubTrigger>その他休暇休業</DropdownMenu.SubTrigger> <DropdownMenu.SubContent> {leaveDefinitions.data ? ( leaveDefinitions.data.map((def) => { if (!def.id) { return null; } toast.open({ severity: "success", title: "勤怠記録を更新しました", }); } finally { onWorkRecordMutated?.(); } }} > 出勤 </DropdownMenu.Item> <DropdownMenu.Item disabled={!isWorkRecordWritable} onSelect={async () => { try { for (const target of targets) { await writeRecord.mutateAsync({ workspaceId: workspaceID, workerId: target.worker.id, writeWorkRecordKey: target.worker.writeWorkRecordKey, workRecord: { dates: target.dates, record: { case: "dayOff", value: {}, }, }, }); } toast.open({ severity: "success", title: "勤怠記録を更新しました", }); } finally { onWorkRecordMutated?.(); } }} > 休日 </DropdownMenu.Item> <DropdownMenu.Item disabled={!isWorkRecordWritable} onSelect={async () => { try { for (const target of targets) { await writeRecord.mutateAsync({ workspaceId: workspaceID, workerId: target.worker.id, writeWorkRecordKey: target.worker.writeWorkRecordKey, workRecord: { dates: target.dates, record: { case: "workingDay", value: { hasWorkerWorked: false, }, }, }, }); } toast.open({ severity: "success", title: "勤怠記録を更新しました", }); } finally { onWorkRecordMutated?.(); } }} > 欠勤 </DropdownMenu.Item> <DropdownMenu.Item disabled={!isWorkRecordWritable} onSelect={async () => { try { for (const target of targets) { for (const date of target.dates) { await writeRecord.mutateAsync({ workspaceId: workspaceID, workerId: target.worker.id, writeWorkRecordKey: target.worker.writeWorkRecordKey, workRecord: { dates: [date], record: { case: "paidLeave", value: {}, }, }, }); } } toast.open({ severity: "success", title: "勤怠記録を更新しました", }); } finally { onWorkRecordMutated?.(); } }} > 年次有給休暇 </DropdownMenu.Item> <DropdownMenu.Sub> <DropdownMenu.SubTrigger>その他休暇休業</DropdownMenu.SubTrigger> <DropdownMenu.SubContent> {leaveDefinitions.data ? ( leaveDefinitions.data.map((def) => { if (!def.id) { return null; } return ( <DropdownMenu.Item key={def.id?.value} disabled={!isWorkRecordWritable} onSelect={async () => { const firstAvailableAt = def.revisions[0]?.startAt && fromProtoDate(def.revisions[0].startAt); const datesTheLeaveNotAvailableFor = firstAvailableAt ? dates.filter((date) => isBefore(date, firstAvailableAt)) : []; if (firstAvailableAt && datesTheLeaveNotAvailableFor.length > 0) { setCanceledDialog( new CannotApplyLeaveToDates( def, firstAvailableAt, datesTheLeaveNotAvailableFor, ), ); return; } try { for (const target of targets) { await writeRecord.mutateAsync({ workspaceId: workspaceID, workerId: target.worker.id, writeWorkRecordKey: target.worker.writeWorkRecordKey, workRecord: { dates: target.dates, record: { case: "workspaceDefinedLeaveId", value: { value: def.id!.value, }, }, }, }); } toast.open({ severity: "success", title: "勤怠記録を更新しました", }); } finally { onWorkRecordMutated?.(); } }} > {def.displayName} </DropdownMenu.Item> ); }) ) : leaveDefinitions.isError ? ( <DropdownMenu.Item disabled>取得できませんでした</DropdownMenu.Item> ) : ( <DropdownMenu.Item disabled>取得中...</DropdownMenu.Item> )} </DropdownMenu.SubContent> </DropdownMenu.Sub> </DropdownMenu.Content> </DropdownMenu.Root> <Dialog.Content> {canceledDialog && ( <> <Dialog.Title>この休暇は設定できません</Dialog.Title> <Dialog.Description> 「{canceledDialog.leave.displayName}」の運用開始日 ( {dateFormatter.format(canceledDialog.availableSince)}) よりも前の日付が範囲に含まれているため、この休暇を設定することはできません。 </Dialog.Description> <ul> {canceledDialog.dates.map((d) => ( <li key={+d}>{dateFormatter.format(d)}</li> ))} </ul> <Flex mt="3" justify="end" align="center"> <Dialog.Close> <Button>閉じる</Button> </Dialog.Close> </Flex> </> )} </Dialog.Content> </Dialog.Root> return ( <DropdownMenu.Item key={def.id?.value} disabled={disabled} onSelect={() => void actions.markAsWorkspaceDefinedLeave(def)} > {def.displayName} </DropdownMenu.Item> ); }) ) : leaveDefinitions.isError ? ( <DropdownMenu.Item disabled>取得できませんでした</DropdownMenu.Item> ) : ( <DropdownMenu.Item disabled>取得中...</DropdownMenu.Item> )} </DropdownMenu.SubContent> </DropdownMenu.Sub> </DropdownMenu.Content> </DropdownMenu.Root> ); };
-
-
-
@@ -44,8 +44,9 @@ import { fromProtoDate, toProtoDate, isSameDate } from "../../../helpers.ts";import * as workerDashboard from "../workers/:id/Dashboard.tsx"; import { Layout } from "../Layout.tsx"; import { Menu, type ActionTarget } from "./Menu.tsx"; import { Menu } from "./Menu.tsx"; import * as Spreadsheet from "./Spreadsheet.ts"; import { useActions } from "./useActions.ts"; import css from "./page.module.css"; export const Title: FC = () => "カレンダー";
-
@@ -226,34 +227,15 @@scrollAreaRef.current.scrollLeft = 0; }, [month]); const targets = useMemo<readonly ActionTarget[]>(() => { if (!query.data) { return []; } return query.data .map<ActionTarget | null>((worker, i) => { const cells = selection.cells.filter((cell) => cell.row === i); if (!cells.length) { return null; } return { worker, dates: cells .map((cell) => { const date = dates[cell.column]; if (!date) { return null; } return toProtoDate(date); }) .filter((d): d is NonNullable<typeof d> => !!d), }; }) .filter((t): t is ActionTarget => !!t); }, [selection.cells, query.data, dates]); const actions = useActions({ workspace, workers: query.data || [], dates, selection, onWorkRecordUpdated() { query.refetch(); }, }); return ( <>
-
@@ -283,13 +265,7 @@ <CaretRightIcon /><VisuallyHidden>次の月へ</VisuallyHidden> </IconButton> <Box flexGrow="1" flexShrink="1" /> <Menu workspaceID={workspace.id} targets={targets} onWorkRecordMutated={() => { query.refetch(); }} /> <Menu actions={actions} workspaceID={workspace.id} /> </Flex> </Container> <Box mt="2" pl={{ md: "2" }}>
-
-
-
@@ -0,0 +1,267 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import * as proto from "@bufbuild/protobuf"; import { NotFoundSchema } from "@yamori/proto/yamori/error/v1/not_found_pb.js"; import { DateSchema } from "@yamori/proto/yamori/type/v1/date_pb.js"; import { type LeaveSchema } from "@yamori/proto/yamori/work_record/v1/leave_pb.js"; import { type WorkerSchema } from "@yamori/proto/yamori/worker/v1/worker_pb.js"; import { type WorkspaceSchema } from "@yamori/proto/yamori/workspace/v1/workspace_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 { isBefore } from "date-fns"; import { useMethodMutation } from "../../../contexts/Service.tsx"; import { useToast } from "../../../contexts/Toast.tsx"; import { IllegalMessageError } from "../../../errors/IllegalMessageError.ts"; import { fromProtoDate, toProtoDate } from "../../../helpers.ts"; import type * as Spreadsheet from "./Spreadsheet.ts"; export interface Actions { isPending: boolean; /** * 選択されたセルを出勤として設定する。 */ markAsWorked(): Promise<void>; /** * 選択されたセルを欠勤として設定する。 */ markAsAbsent(): Promise<void>; /** * 選択されたセルを休日として設定する。 */ markAsDayOff(): Promise<void>; /** * 選択されたセルを法定・特別休暇として設定する。 */ markAsWorkspaceDefinedLeave( leave: proto.MessageShape<typeof LeaveSchema>, ): Promise<void>; /** * 選択されたセルを年次有給休暇として設定する。 */ markAsPaidLeave(): Promise<void>; } export interface UseActionsInput { workspace: proto.MessageShape<typeof WorkspaceSchema>; selection: Spreadsheet.Selection; workers: readonly proto.MessageShape<typeof WorkerSchema>[]; dates: readonly Date[]; onWorkRecordUpdated?(): void; } export function useActions({ workspace, selection, workers, dates, onWorkRecordUpdated, }: UseActionsInput): Actions { const toast = useToast(); const mutation = useMethodMutation({ service: "yamori.worker.v1.WorkerService", method: "WriteWorkRecord", request: { schema: WriteWorkRecordRequestSchema, }, response: { schema: WriteWorkRecordResponseSchema, }, mapResponse(resp) { if (resp.result.case === "ok") { return resp.result.value.workRecords; } if (typeof resp.result.case === "undefined") { throw new IllegalMessageError(resp); } throw resp.result.value; }, options: { 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 runForWorkers = async ( callback: ( worker: proto.MessageShape<typeof WorkerSchema>, dates: proto.MessageShape<typeof DateSchema>[], ) => Promise<void>, ) => { let hasUpdatedAny = false; try { for (let i = 0, l = workers.length; i < l; i++) { const cells = selection.cells .filter((cell) => cell.row === i) .map((cell) => { const date = dates[cell.column]; if (!date) { return null; } return toProtoDate(date); }) .filter((d): d is proto.MessageShape<typeof DateSchema> => !!d); const worker = workers[i]; if (!worker || !cells.length) { continue; } if (!worker.writeWorkRecordKey) { toast.open({ severity: "warn", title: "書き込み権限がありません", description: `労働者「${worker.displayName}」への書き込み権限がありません。`, }); continue; } await callback(worker, cells); hasUpdatedAny = true; } } finally { if (hasUpdatedAny) { toast.open({ severity: "success", title: "勤怠記録を更新しました", }); } onWorkRecordUpdated?.(); } }; return { isPending: mutation.isPending, markAsDayOff() { return runForWorkers(async (worker, dates) => { await mutation.mutateAsync({ workspaceId: workspace.id, workerId: worker.id, writeWorkRecordKey: worker.writeWorkRecordKey, workRecord: { dates, record: { case: "dayOff", value: {}, }, }, }); }); }, markAsWorked() { return runForWorkers(async (worker, dates) => { await mutation.mutateAsync({ workspaceId: workspace.id, workerId: worker.id, writeWorkRecordKey: worker.writeWorkRecordKey, workRecord: { dates, record: { case: "workingDay", value: { hasWorkerWorked: true, }, }, }, }); }); }, markAsAbsent() { return runForWorkers(async (worker, dates) => { await mutation.mutateAsync({ workspaceId: workspace.id, workerId: worker.id, writeWorkRecordKey: worker.writeWorkRecordKey, workRecord: { dates, record: { case: "workingDay", value: { hasWorkerWorked: false, }, }, }, }); }); }, markAsPaidLeave() { return runForWorkers(async (worker, dates) => { await mutation.mutateAsync({ workspaceId: workspace.id, workerId: worker.id, writeWorkRecordKey: worker.writeWorkRecordKey, workRecord: { dates, record: { case: "paidLeave", value: {}, }, }, }); }); }, markAsWorkspaceDefinedLeave(leave) { return runForWorkers(async (worker, dates) => { const firstAvailableAt = leave.revisions[0]?.startAt && fromProtoDate(leave.revisions[0].startAt); const datesTheLeaveNotAvailableFor = firstAvailableAt ? dates.filter((date) => isBefore(fromProtoDate(date), firstAvailableAt)) : []; if (firstAvailableAt && datesTheLeaveNotAvailableFor.length > 0) { toast.open({ severity: "warn", title: `${leave.displayName}は利用できません`, description: `対象範囲に${leave.displayName}の運用開始日以前の日付が含まれているため${worker.displayName}への書き込みをスキップします。`, }); return; } await mutation.mutateAsync({ workspaceId: workspace.id, workerId: worker.id, writeWorkRecordKey: worker.writeWorkRecordKey, workRecord: { dates, record: { case: "workspaceDefinedLeaveId", value: leave.id!, }, }, }); }); }, }; }
-