Changes
2 changed files (+197/-99)
-
-
@@ -0,0 +1,46 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only export type TabluarData = (string | number)[][]; /** * <https://www.iana.org/assignments/media-types/text/tab-separated-values> */ export function createTSV(data: TabluarData): string { return data .map((row) => row .map((cell) => { if (typeof cell === "number") { return cell.toString(10); } return cell.replace(/[\r\n\t]/g, ""); }) .join("\t"), ) .join("\r\n"); } /** * <https://datatracker.ietf.org/doc/html/rfc4180> */ export function createCSV(data: TabluarData): string { return data .map((row) => row .map((cell) => { if (typeof cell === "number") { return cell.toString(10); } if (!/[,"\n]/.test(cell)) { return cell; } return `"${cell.replace(/"/g, `""`)}"`; }) .join(","), ) .join("\r\n"); }
-
-
-
@@ -35,7 +35,8 @@ import { fromProtoDate, toProtoDate } from "../../../helpers.ts";import * as workerDashboard from "../workers/:id/Dashboard.tsx"; import { Layout } from "../Layout.tsx"; import { buildSearchParams, deserializeDate, href } from "./meta.ts"; import { createCSV, createTSV } from "./exports.ts"; import { buildSearchParams, deserializeDate, serializeDate, href } from "./meta.ts"; import css from "./page.module.css"; export { href, pattern } from "./meta.ts";
-
@@ -181,6 +182,90 @@ const datesCount = useMemo<number>(() => {return differenceInCalendarDays(fromProtoDate(until), fromProtoDate(since)) + 1; }, [since, until]); const header = useMemo<{ name: string; abbr?: string }[]>(() => { return [ { name: "労働者名", }, { name: workspaceConfig.data?.abbreviations?.worked || "出勤", }, { name: workspaceConfig.data?.abbreviations?.dayoff || "休日", }, { name: workspaceConfig.data?.abbreviations?.skipWork || "欠勤", }, { name: "年次有給休暇", abbr: workspaceConfig.data?.abbreviations?.paidLeave, }, ...(workspaceConfig.data?.leaveDefinitions.map((def) => { return { name: def.displayName, abbr: def.abbreviationName, }; }) ?? []), { name: "未入力", }, ]; }, [workspaceConfig.data]); const body = useMemo<[proto.MessageShape<typeof WorkerSchema>, ...number[]][]>(() => { return ( workers.data?.map((worker) => { let worked = 0; let dayoff = 0; let skipped = 0; let paidLeave = 0; const workspaceDefinedLeaves = new Map<string, number>(); for (const record of worker.workRecords) { switch (record.record.case) { case "workingDay": if (record.record.value.hasWorkerWorked) { worked++; } else { skipped++; } break; case "dayOff": dayoff++; break; case "paidLeave": paidLeave++; break; case "workspaceDefinedLeave": if (record.record.value.id?.value) { const current = workspaceDefinedLeaves.get(record.record.value.id.value); workspaceDefinedLeaves.set( record.record.value.id.value, (current ?? 0) + 1, ); } break; } } const emptyDates = datesCount - worker.workRecords.filter((r) => !!r.record.case).length; return [ worker, worked, dayoff, skipped, paidLeave, ...(workspaceConfig.data?.leaveDefinitions.map((def) => { return (def.id && workspaceDefinedLeaves.get(def.id.value)) || 0; }) ?? []), emptyDates, ]; }) ?? [] ); }, [workers.data, workspaceConfig.data]); return ( <Flex position="absolute" inset="0" direction="column"> {workspaceConfig.isError && (
-
@@ -249,8 +334,46 @@ <DropdownMenu.TriggerIcon /></Button> </DropdownMenu.Trigger> <DropdownMenu.Content align="end"> <DropdownMenu.Item disabled>TSVファイル</DropdownMenu.Item> <DropdownMenu.Item disabled>CSVファイル</DropdownMenu.Item> <DropdownMenu.Item disabled={!workers.isFetched || !workspaceConfig.isFetched} onClick={() => { const tsv = createTSV([ header.map(({ name }) => name), ...body.map(([worker, ...cells]) => [worker.displayName, ...cells]), ]); const file = new Blob([tsv], { type: "text/tab-separated-values;charset=UTF-8", }); const anchor = document.createElement("a"); anchor.download = `${serializeDate(since)}_${serializeDate(until)}.tsv`; anchor.href = URL.createObjectURL(file); anchor.click(); }} > TSVファイル </DropdownMenu.Item> <DropdownMenu.Item disabled={!workers.isFetched || !workspaceConfig.isFetched} onClick={() => { const csv = createCSV([ header.map(({ name }) => name), ...body.map(([worker, ...cells]) => [worker.displayName, ...cells]), ]); const file = new Blob([csv], { type: "text/csv;charset=UTF-8" }); const anchor = document.createElement("a"); anchor.download = `${serializeDate(since)}_${serializeDate(until)}.csv`; anchor.href = URL.createObjectURL(file); anchor.click(); }} > CSVファイル </DropdownMenu.Item> </DropdownMenu.Content> </DropdownMenu.Root> </Flex>
-
@@ -259,120 +382,49 @@ <Box mt="2" p="2" flexGrow="1" flexShrink="1" minWidth="0" minHeight="0"><Table.Root style={{ maxHeight: "100%" }}> <Table.Header> <Table.Row> <Table.ColumnHeaderCell className={css.rowColumnHeader}> 労働者名 </Table.ColumnHeaderCell> <Table.ColumnHeaderCell className={css.columnHeader} justify="center"> {workspaceConfig.data?.abbreviations?.worked ?? "出勤"} </Table.ColumnHeaderCell> <Table.ColumnHeaderCell className={css.columnHeader} justify="center"> {workspaceConfig.data?.abbreviations?.dayoff ?? "休日"} </Table.ColumnHeaderCell> <Table.ColumnHeaderCell className={css.columnHeader} justify="center"> {workspaceConfig.data?.abbreviations?.skipWork ?? "欠勤"} </Table.ColumnHeaderCell> <Table.ColumnHeaderCell className={css.columnHeader} justify="center"> <Tooltip content="年次有給休暇"> <Text> {workspaceConfig.data?.abbreviations?.paidLeave ?? "年次有給休暇"} </Text> </Tooltip> </Table.ColumnHeaderCell> {workspaceConfig.data?.leaveDefinitions.map((def) => { {header.map(({ name, abbr }, i) => { return ( <Table.ColumnHeaderCell key={def.id?.value} className={css.columnHeader} justify="center" key={i} className={i === 0 ? css.rowColumnHeader : css.columnHeader} justify={i === 0 ? "start" : "center"} > <Tooltip content={def.displayName}> <Text>{def.abbreviationName || def.displayName}</Text> </Tooltip> {abbr ? ( <Tooltip content={name}> <Text>{abbr}</Text> </Tooltip> ) : ( name )} </Table.ColumnHeaderCell> ); })} <Table.ColumnHeaderCell className={css.columnHeader} justify="center"> 未入力 </Table.ColumnHeaderCell> </Table.Row> </Table.Header> <Table.Body> {workers.data?.map((worker) => { let worked = 0; let dayoff = 0; let skipped = 0; let paidLeave = 0; const workspaceDefinedLeaves = new Map<string, number>(); for (const record of worker.workRecords) { switch (record.record.case) { case "workingDay": if (record.record.value.hasWorkerWorked) { worked++; } else { skipped++; } break; case "dayOff": dayoff++; break; case "paidLeave": paidLeave++; break; case "workspaceDefinedLeave": if (record.record.value.id?.value) { const current = workspaceDefinedLeaves.get( record.record.value.id.value, ); workspaceDefinedLeaves.set( record.record.value.id.value, (current ?? 0) + 1, {body.map((row, i) => { return ( <Table.Row key={i}> {row.map((cell, j, cells) => { if (typeof cell !== "number") { return ( <Table.RowHeaderCell key={j} className={css.rowHeader}> <Link href={workerDashboard.href({ workspace, worker: cell })}> {cell.displayName} </Link> </Table.RowHeaderCell> ); } break; } } const emptyDates = datesCount - worker.workRecords.filter((r) => !!r.record.case).length; return ( <Table.Row key={worker.id?.value}> <Table.RowHeaderCell className={css.rowHeader}> <Link href={workerDashboard.href({ workspace, worker })}> {worker.displayName} </Link> </Table.RowHeaderCell> <Table.Cell className={css.cell} justify="center"> <DayCount count={worked} /> </Table.Cell> <Table.Cell className={css.cell} justify="center"> <DayCount count={dayoff} /> </Table.Cell> <Table.Cell className={css.cell} justify="center"> <DayCount count={skipped} /> </Table.Cell> <Table.Cell className={css.cell} justify="center"> <DayCount count={paidLeave} /> </Table.Cell> {workspaceConfig.data?.leaveDefinitions.map((def) => { return ( <Table.Cell key={def.id?.value} className={css.cell} justify="center" > <Table.Cell key={j} className={css.cell} justify="center"> <DayCount count={ (def.id && workspaceDefinedLeaves.get(def.id.value)) || 0 } color={j === cells.length - 1 ? "red" : undefined} count={cell} /> </Table.Cell> ); })} <Table.Cell className={css.cell} justify="center"> <DayCount color="red" count={emptyDates} /> </Table.Cell> </Table.Row> ); })}
-