Skip to content

Commit a40e60d

Browse files
authored
Merge pull request #136 from LevelCapTech/copilot/add-progress-column-to-task-table
feat: [IMPLEMENT] タスクテーブル進捗列の追加
2 parents 20ccee1 + 88158b2 commit a40e60d

12 files changed

Lines changed: 241 additions & 6 deletions

README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,32 @@ const calendarConfig: CalendarConfig = {
205205
- TaskListHeader: `React.FC<{ headerHeight: number; rowWidth: string; fontFamily: string; fontSize: string;}>;`
206206
- TaskListTable: `React.FC<{ rowHeight: number; rowWidth: string; fontFamily: string; fontSize: string; locale: string; tasks: Task[]; selectedTaskId: string; setSelectedTask: (taskId: string) => void; }>;`
207207

208+
### TaskList 列の表示制御(visibleFields)
209+
210+
TaskList の表示列は `visibleFields` で指定します。`DEFAULT_VISIBLE_FIELDS` に progress 列は含まれないため、**進捗列は opt-in** です。
211+
212+
```typescript
213+
import { DEFAULT_VISIBLE_FIELDS, VisibleField } from "@levelcaptech/gantt-task-react-custom";
214+
215+
const visibleFields: VisibleField[] = [
216+
"name",
217+
"start",
218+
"end",
219+
"progress",
220+
...DEFAULT_VISIBLE_FIELDS.filter(field =>
221+
!["name", "start", "end"].includes(field)
222+
),
223+
];
224+
225+
<Gantt
226+
tasks={tasks}
227+
visibleFields={visibleFields}
228+
onCellCommit={onCellCommit}
229+
/>;
230+
```
231+
232+
progress セルの編集では、入力値は 5 の倍数に丸められ、`onCellCommit` には `"0"〜"100"` の文字列が通知されます。
233+
208234
### Task
209235

210236
| パラメーター名 || 説明 |

example/src/App.tsx

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,15 @@ const JapaneseTooltip: React.FC<{
100100

101101
type TaskFieldValue = Task[VisibleField];
102102

103+
// 表示順と重複除外に使う基準の列(進捗列を含む定義は以下で構築する)
104+
const BASE_VISIBLE_FIELDS: VisibleField[] = ["name", "start", "end"];
105+
106+
const VISIBLE_FIELDS_WITH_PROGRESS: VisibleField[] = [
107+
...BASE_VISIBLE_FIELDS,
108+
"progress",
109+
...DEFAULT_VISIBLE_FIELDS.filter(field => !BASE_VISIBLE_FIELDS.includes(field)),
110+
];
111+
103112
const resolveCellCommitValue = (
104113
columnId: VisibleField,
105114
value: string,
@@ -114,7 +123,8 @@ const resolveCellCommitValue = (
114123
return Number.isNaN(parsedDate.getTime()) ? fallbackValue : parsedDate;
115124
}
116125
case "plannedEffort":
117-
case "actualEffort": {
126+
case "actualEffort":
127+
case "progress": {
118128
const parsedNumber = Number(value);
119129
return Number.isNaN(parsedNumber) ? fallbackValue : parsedNumber;
120130
}
@@ -265,7 +275,7 @@ const App = () => {
265275
locale="ja-JP"
266276
calendar={calendarConfig}
267277
TooltipContent={JapaneseTooltip}
268-
visibleFields={DEFAULT_VISIBLE_FIELDS}
278+
visibleFields={VISIBLE_FIELDS_WITH_PROGRESS}
269279
onTaskUpdate={handleTaskUpdate}
270280
onCellCommit={handleCellCommit}
271281
effortDisplayUnit={effortUnit}
@@ -287,7 +297,7 @@ const App = () => {
287297
locale="ja-JP"
288298
calendar={calendarConfig}
289299
TooltipContent={JapaneseTooltip}
290-
visibleFields={DEFAULT_VISIBLE_FIELDS}
300+
visibleFields={VISIBLE_FIELDS_WITH_PROGRESS}
291301
onTaskUpdate={handleTaskUpdate}
292302
onCellCommit={handleCellCommit}
293303
effortDisplayUnit={effortUnit}

src/components/task-list/overlay-editor.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ const resolveOverlayInputType = (
4747
return "date";
4848
case "plannedEffort":
4949
case "actualEffort":
50+
case "progress":
5051
return "number";
5152
case "process":
5253
case "status":
@@ -116,6 +117,13 @@ export const OverlayEditor: React.FC<OverlayEditorProps> = ({
116117
}
117118
return [];
118119
}, [editingState.columnId]);
120+
const numberInputAttributes = useMemo<React.InputHTMLAttributes<HTMLInputElement>>(
121+
() =>
122+
editingState.columnId === "progress"
123+
? { min: 0, max: 100, step: 5 }
124+
: {},
125+
[editingState.columnId]
126+
);
119127

120128
const portalRoot = useMemo(() => {
121129
if (typeof document === "undefined") {
@@ -416,6 +424,7 @@ export const OverlayEditor: React.FC<OverlayEditorProps> = ({
416424
defaultValue={defaultValueRef.current}
417425
style={{ height: "100%" }}
418426
ref={handleInputElementRef}
427+
{...(inputType === "number" ? numberInputAttributes : {})}
419428
readOnly={editingState.pending}
420429
onKeyDown={handleKeyDown}
421430
onBlur={handleBlur}

src/components/task-list/task-list-header.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ export const TaskListHeaderDefault: React.FC<{
4646
name: "タスク名",
4747
start: "開始日",
4848
end: "終了日",
49+
progress: "進捗",
4950
process: "工程",
5051
assignee: "担当者",
5152
plannedStart: "予定開始",

src/components/task-list/task-list-table.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { getDefaultWidth, TaskListEditingStateContext } from "./task-list";
55
import {
66
formatDate,
77
formatEffort,
8+
formatProgress,
89
getStatusBadgeText,
910
getStatusColor,
1011
normalizeProcess,
@@ -57,6 +58,7 @@ export const TaskListTableDefault: React.FC<{
5758
"name",
5859
"start",
5960
"end",
61+
"progress",
6062
"process",
6163
"assignee",
6264
"plannedStart",
@@ -273,6 +275,8 @@ export const TaskListTableDefault: React.FC<{
273275
return <span>{formatDate(t.start)}</span>;
274276
case "end":
275277
return <span>{formatDate(t.end)}</span>;
278+
case "progress":
279+
return <span>{formatProgress(t.progress)}</span>;
276280
case "process":
277281
return <span>{normalizeProcess(t.process)}</span>;
278282
case "assignee":

src/components/task-list/task-list.tsx

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
import {
2323
formatDate,
2424
parseDateFromInput,
25+
parseProgressInput,
2526
sanitizeEffortInput,
2627
} from "../../helpers/task-helper";
2728
import { ParsedTime, parseTimeString } from "../../helpers/time-helper";
@@ -306,6 +307,16 @@ export const TaskList: React.FC<TaskListProps> = ({
306307
const rowId = editingState.rowId;
307308
const columnId = editingState.columnId;
308309
const task = tasks.find(row => row.id === rowId);
310+
const resolveProgressCommit = () => {
311+
if (columnId !== "progress") {
312+
return null;
313+
}
314+
const parsedValue = parseProgressInput(value);
315+
if (parsedValue === null) {
316+
return { invalid: true, normalizedValue: null };
317+
}
318+
return { invalid: false, normalizedValue: `${parsedValue}` };
319+
};
309320
const resolveActualsCommit = () => {
310321
if (!task) {
311322
return null;
@@ -364,6 +375,21 @@ export const TaskList: React.FC<TaskListProps> = ({
364375
updatedFields: Object.keys(updatedFields).length > 0 ? updatedFields : null,
365376
};
366377
};
378+
const progressCommit = resolveProgressCommit();
379+
if (progressCommit?.invalid) {
380+
setEditingState(prev => {
381+
if (
382+
prev.mode !== "editing" ||
383+
prev.pending ||
384+
prev.rowId !== rowId ||
385+
prev.columnId !== columnId
386+
) {
387+
return prev;
388+
}
389+
return { ...prev, errorMessage: "0〜100 の数値を入力してください" };
390+
});
391+
return;
392+
}
367393
const actualsCommit = resolveActualsCommit();
368394
setEditingState(prev => {
369395
if (
@@ -377,7 +403,8 @@ export const TaskList: React.FC<TaskListProps> = ({
377403
return { ...prev, pending: true, errorMessage: null };
378404
});
379405
try {
380-
const commitValue = actualsCommit?.normalizedValue ?? value;
406+
const commitValue =
407+
progressCommit?.normalizedValue ?? actualsCommit?.normalizedValue ?? value;
381408
await onCellCommit({ rowId, columnId, value: commitValue, trigger });
382409
if (actualsCommit?.updatedFields && onUpdateTask) {
383410
onUpdateTask(rowId, actualsCommit.updatedFields);

src/helpers/task-helper.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,34 @@ export const sanitizeEffortInput = (value: string) => {
7676
return parsed;
7777
};
7878

79+
const clampProgress = (value: number) => Math.min(100, Math.max(0, value));
80+
81+
/** 表示用に 0〜100 の範囲へクランプする(5刻み丸めは行わない) */
82+
export const normalizeProgress = (progress?: number) => {
83+
if (progress === undefined || !Number.isFinite(progress)) {
84+
return null;
85+
}
86+
return clampProgress(progress);
87+
};
88+
89+
export const formatProgress = (progress?: number): string => {
90+
const normalized = normalizeProgress(progress);
91+
return normalized === null ? "" : `${normalized}`;
92+
};
93+
94+
/** commit 用に 5 刻みへ丸めたうえで 0〜100 にクランプする */
95+
export const parseProgressInput = (value: string) => {
96+
if (value.trim() === "") {
97+
return null;
98+
}
99+
const parsed = Number(value);
100+
if (!Number.isFinite(parsed)) {
101+
return null;
102+
}
103+
const rounded = Math.round(parsed / 5) * 5;
104+
return clampProgress(rounded);
105+
};
106+
79107
const DEFAULT_TASK_PROCESS: TaskProcess = (
80108
TASK_PROCESS_OPTIONS.includes("その他")
81109
? "その他"

src/test/overlay-editor.test.tsx

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,7 @@ describe("OverlayEditor", () => {
164164
["name", "INPUT", "text", "タスク名"],
165165
["start", "INPUT", "date", "2026-01-01"],
166166
["plannedEffort", "INPUT", "number", 8],
167+
["progress", "INPUT", "number", 45],
167168
["process", "SELECT", "", "レビュー"],
168169
["status", "SELECT", "", "完了"],
169170
])(
@@ -211,6 +212,47 @@ describe("OverlayEditor", () => {
211212
}
212213
);
213214

215+
it("sets progress input constraints", async () => {
216+
const rectSpy = jest
217+
.spyOn(HTMLElement.prototype, "getBoundingClientRect")
218+
.mockReturnValue(rect as DOMRect);
219+
const rafSpy = jest
220+
.spyOn(window, "requestAnimationFrame")
221+
.mockImplementation(callback => {
222+
callback(0);
223+
return 1;
224+
});
225+
const { taskListRef, headerRef, bodyRef } = createRefs();
226+
227+
render(
228+
<div ref={taskListRef}>
229+
<div ref={headerRef} />
230+
<div ref={bodyRef}>
231+
<div data-row-id="task-1" data-column-id="progress">
232+
45
233+
</div>
234+
</div>
235+
<OverlayEditor
236+
editingState={createEditingState("progress", false)}
237+
taskListRef={taskListRef}
238+
headerContainerRef={headerRef}
239+
bodyContainerRef={bodyRef}
240+
onCommit={jest.fn().mockResolvedValue(undefined)}
241+
onCancel={jest.fn()}
242+
/>
243+
</div>
244+
);
245+
246+
const overlayInput = await screen.findByTestId("overlay-editor-input");
247+
248+
expect(overlayInput).toHaveAttribute("min", "0");
249+
expect(overlayInput).toHaveAttribute("max", "100");
250+
expect(overlayInput).toHaveAttribute("step", "5");
251+
252+
rectSpy.mockRestore();
253+
rafSpy.mockRestore();
254+
});
255+
214256
it("focuses the input when editing starts", async () => {
215257
const rectSpy = jest
216258
.spyOn(HTMLElement.prototype, "getBoundingClientRect")

src/test/task-helper.test.tsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import {
33
parseDateFromInput,
44
formatEffort,
55
sanitizeEffortInput,
6+
formatProgress,
7+
parseProgressInput,
68
normalizeProcess,
79
normalizeStatus,
810
getStatusBadgeText,
@@ -67,6 +69,28 @@ describe("task-helper sanitizeEffortInput", () => {
6769
});
6870
});
6971

72+
describe("task-helper progress helpers", () => {
73+
it("formats progress with clamp only", () => {
74+
expect(formatProgress(42)).toBe("42");
75+
expect(formatProgress(101)).toBe("100");
76+
expect(formatProgress(-3)).toBe("0");
77+
});
78+
79+
it("returns empty string when progress is invalid", () => {
80+
expect(formatProgress(Number.NaN)).toBe("");
81+
});
82+
83+
it("parses progress input with rounding", () => {
84+
expect(parseProgressInput("47")).toBe(45);
85+
expect(parseProgressInput("100")).toBe(100);
86+
});
87+
88+
it("rejects invalid progress input", () => {
89+
expect(parseProgressInput("")).toBeNull();
90+
expect(parseProgressInput("abc")).toBeNull();
91+
});
92+
});
93+
7094
describe("task-helper normalize helpers", () => {
7195
it("normalizes process to defined options", () => {
7296
expect(normalizeProcess("設計")).toBe("設計");

0 commit comments

Comments
 (0)