Changes
2 changed files (+357/-129)
-
-
@@ -0,0 +1,180 @@// 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, DropdownMenu } 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 { type DateSchema } from "@yamori/proto/yamori/type/v1/date_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 { type FC } from "react"; import { useMethodMutation } from "../../../contexts/Service.tsx"; import { useToast } from "../../../contexts/Toast.tsx"; import { IllegalMessageError } from "../../../errors/IllegalMessageError.ts"; export interface ActionTarget { worker: proto.MessageShape<typeof WorkerSchema>; dates: proto.MessageShape<typeof DateSchema>[]; } export interface MenuProps extends Pick<ButtonProps, "size" | "variant" | "color" | "className" | "style"> { workspaceID: proto.MessageShape<typeof WorkspaceSchema>["id"]; targets: readonly ActionTarget[]; onWorkRecordMutated?(): void; } export const Menu: FC<MenuProps> = ({ targets, workspaceID, onWorkRecordMutated, ...rest }) => { const toast = useToast(); 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() { toast.open({ severity: "danger", title: "勤怠記録の更新に失敗しました", }); }, }, }); return ( <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, }, }, }, }); } 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.Content> </DropdownMenu.Root> ); };
-
-
-
@@ -40,6 +40,7 @@ import { IllegalMessageError } from "../../../errors/IllegalMessageError.ts";import { fromProtoDate, toProtoDate, isSameDate } from "../../../helpers.ts"; import { Layout } from "../Layout.tsx"; import { Menu, type ActionTarget } from "./Menu.tsx"; import * as Spreadsheet from "./Spreadsheet.ts"; import css from "./page.module.css";
-
@@ -98,9 +99,11 @@ interface BodyProps {workspaceID: proto.MessageShape<typeof WorkspaceSchema>["id"]; month: proto.MessageShape<typeof DateSchema>; onChangeMonth(month: proto.MessageShape<typeof DateSchema>): void; } const Body: FC<BodyProps> = ({ workspaceID, month }) => { const Body: FC<BodyProps> = ({ workspaceID, month, onChangeMonth }) => { const { start, end } = useMemo(() => { const d = fromProtoDate(month);
-
@@ -185,103 +188,174 @@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]); return ( <ScrollArea ref={scrollAreaRef} scrollbars="horizontal" size="2"> <Spreadsheet.Root asChild selection={selection} onSelectionChange={setSelection} rows={query.data?.length ?? 0} columns={dates.length} > <Grid columns={`max-content repeat(${dates.length}, 5rem)`} pb="4"> <Spreadsheet.Row className={css.row} row={-1}> <Spreadsheet.ColumnHeader asChild> <Box className={`${css.rowHeaderCell} ${css.columnHeaderCell}`} position="sticky" left="0" top="0" style={{ backgroundColor: "var(--color-background)", zIndex: 50 }} /> </Spreadsheet.ColumnHeader> {dates.map((d) => ( <Spreadsheet.ColumnHeader key={+d} asChild> <Flex 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> </Spreadsheet.ColumnHeader> ))} </Spreadsheet.Row> {query.data?.map((worker, workerIndex) => { return ( <Spreadsheet.Row key={worker.id?.value} className={css.row} row={workerIndex} > <Spreadsheet.RowHeader asChild> <Flex className={css.columnHeaderCell} <> <Flex align="center" gap="1"> <IconButton variant="soft" size="2" onClick={() => { onChangeMonth(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={() => { onChangeMonth(toProtoDate(addMonths(fromProtoDate(month), 1))); }} > <CaretRightIcon /> <VisuallyHidden>次の月へ</VisuallyHidden> </IconButton> <Box flexGrow="1" flexShrink="1" /> <Menu workspaceID={workspaceID} targets={targets} onWorkRecordMutated={() => { query.refetch(); }} /> </Flex> <Section size="2" maxWidth="100%" minWidth="0"> <ScrollArea ref={scrollAreaRef} scrollbars="horizontal" size="2"> <Spreadsheet.Root asChild selection={selection} onSelectionChange={setSelection} rows={query.data?.length ?? 0} columns={dates.length} > <Grid columns={`max-content repeat(${dates.length}, 5rem)`} pb="4"> <Spreadsheet.Row className={css.row} row={-1}> <Spreadsheet.ColumnHeader asChild> <Box className={`${css.rowHeaderCell} ${css.columnHeaderCell}`} position="sticky" left="0" style={{ backgroundColor: "var(--color-background)", zIndex: 30 }} align="center" gap="2" px="2" py="4" top="0" style={{ backgroundColor: "var(--color-background)", zIndex: 50 }} /> </Spreadsheet.ColumnHeader> {dates.map((d) => ( <Spreadsheet.ColumnHeader key={+d} asChild> <Flex 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> </Spreadsheet.ColumnHeader> ))} </Spreadsheet.Row> {query.data?.map((worker, workerIndex) => { return ( <Spreadsheet.Row key={worker.id?.value} className={css.row} row={workerIndex} > <Avatar size="2" fallback={worker.displayName[0] || <PersonIcon />} /> <Text>{worker.displayName}</Text> </Flex> </Spreadsheet.RowHeader> {dates.map((date, dateIndex) => { const d = toProtoDate(date); const record = worker.workRecords.find( (record) => record.date && isSameDate(record.date, d), ); const contents = record && <WorkRecordBadges workRecord={record} />; return ( <Spreadsheet.Cell key={+date} asChild column={dateIndex}> <Spreadsheet.RowHeader asChild> <Flex className={`${css.recordCell} ${css.writableCell}`} direction="column" gap="1" p="1" minWidth="0" className={css.columnHeaderCell} position="sticky" left="0" style={{ backgroundColor: "var(--color-background)", zIndex: 30 }} align="center" gap="2" px="2" py="4" > {contents} <Avatar size="2" fallback={worker.displayName[0] || <PersonIcon />} /> <Text>{worker.displayName}</Text> </Flex> </Spreadsheet.Cell> ); })} </Spreadsheet.Row> ); })} </Grid> </Spreadsheet.Root> </ScrollArea> </Spreadsheet.RowHeader> {dates.map((date, dateIndex) => { const d = toProtoDate(date); const record = worker.workRecords.find( (record) => record.date && isSameDate(record.date, d), ); const contents = record && <WorkRecordBadges 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> ); })} </Spreadsheet.Row> ); })} </Grid> </Spreadsheet.Root> </ScrollArea> </Section> </> ); };
-
@@ -325,44 +399,18 @@ }, [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> <Body workspaceID={workspace.id} month={month} onChangeMonth={(month) => { navigation.replace( href({ workspace, month, }), ); }} /> </Layout> ); };
-