推定時間ピッカー
:LiTarget: 用途
「時間+分」を別フィールドで入力する推定時間UI。クイックボタン付き。
:LiSparkle: 特徴
- 時間/分の2フィールド
- クイック入力ボタン
- バリデーション
:LiCode: 実コード(SCALE Base より自動抽出)
:LiInfo:
components/tasks/EstimateMinutesPicker.tsxの中身そのもの。コピペ即可。
"use client";
import { useEffect, useState } from "react";
const PRESETS = [5, 10, 15, 20, 30, 45, 60, 90, 120, 150, 180] as const;
export function fmtEstimateLabel(m: number): string {
if (m < 60) return `${m}分`;
const h = Math.floor(m / 60);
const r = m % 60;
if (r === 0) return `${h}時間`;
if (r === 30) return `${h}時間半`;
return `${h}時間${r}分`;
}
interface Props {
value: number | undefined;
onChange: (v: number | undefined) => void;
selectClassName?: string;
inputClassName?: string;
className?: string;
disabled?: boolean;
/** 初期未設定時に表示する選択肢のラベル */
emptyLabel?: string;
}
/**
* 共通の目安時間プルダウン。
* プリセット(5/10/15/20/30/45/60/90/120/150/180)+ 「記入式(自由入力)」。
* value がプリセット外なら自動で「記入式」にスイッチして数値入力欄を表示。
*/
export default function EstimateMinutesPicker({
value,
onChange,
selectClassName,
inputClassName,
className,
disabled,
emptyLabel = "未設定",
}: Props) {
const isPreset = value === undefined || (typeof value === 'number' && (PRESETS as readonly number[]).includes(value));
const [custom, setCustom] = useState<boolean>(value !== undefined && !isPreset);
// value が外部から更新されたら custom 判定を更新
useEffect(() => {
if (value === undefined) return;
if (!(PRESETS as readonly number[]).includes(value)) setCustom(true);
}, [value]);
const baseSelect = "bg-bg3 border border-border rounded-lg px-2 py-1.5 text-sm text-text focus:outline-none focus:border-text3";
const baseInput = "bg-bg3 border border-border rounded-lg px-2 py-1.5 text-sm text-text placeholder:text-text3 focus:outline-none focus:border-text3 w-24";
return (
<div className={`flex items-center gap-2 ${className ?? ""}`}>
<select
disabled={disabled}
value={custom ? "__custom__" : (typeof value === 'number' ? String(value) : "")}
onChange={(e) => {
const v = e.target.value;
if (v === "__custom__") {
setCustom(true);
// 切り替えた瞬間は値を変えない
return;
}
setCustom(false);
if (v === "") onChange(undefined);
else onChange(Number(v));
}}
className={selectClassName || baseSelect}
>
<option value="">{emptyLabel}</option>
{PRESETS.map((m) => (
<option key={m} value={String(m)}>{fmtEstimateLabel(m)}</option>
))}
<option value="__custom__">記入式(自由入力)</option>
</select>
{custom && (() => {
// 自由入力は「時間」「分」の2フィールドに分けて扱う(2026-05-04 仕様変更)
// 旧仕様: 単一の数値入力で「分」固定 → 「8」と入れると 8分になり、8時間にしたいケースで困る
// 新仕様: 時間と分を別フィールドで明示。内部値は分単位で保持
const totalMin = typeof value === 'number' ? value : 0;
const h = Math.floor(totalMin / 60);
const m = totalMin % 60;
const updateFromHM = (newH: number, newM: number) => {
const total = Math.max(0, newH * 60 + newM);
onChange(total === 0 && typeof value !== 'number' ? undefined : total);
};
const hourInputCls = inputClassName || (baseInput + ' w-16');
const minInputCls = inputClassName || (baseInput + ' w-16');
return (
<div className="flex items-center gap-1">
<input
type="number"
min={0}
step={1}
disabled={disabled}
value={typeof value === 'number' ? h : ""}
onChange={(e) => {
const v = e.target.value;
const newH = v === "" ? 0 : Math.max(0, Number(v));
updateFromHM(newH, m);
}}
placeholder="0"
className={hourInputCls}
title="時間"
/>
<span className="text-text2 text-xs shrink-0">時間</span>
<input
type="number"
min={0}
max={59}
step={5}
disabled={disabled}
value={typeof value === 'number' ? m : ""}
onChange={(e) => {
const v = e.target.value;
const newM = v === "" ? 0 : Math.max(0, Math.min(59, Number(v)));
updateFromHM(h, newM);
}}
placeholder="0"
className={minInputCls}
title="分"
/>
<span className="text-text2 text-xs shrink-0">分</span>
</div>
);
})()}
</div>
);
}
:LiFolder: ソースファイルのパス
/Users/oogushiyuuki/Library/CloudStorage/GoogleDrive-y-ogushi@scale-group.co.jp/マイドライブ/AI/scale-base/components/tasks/EstimateMinutesPicker.tsx
:LiHandPointer: 使い方
対象プロジェクトに該当ファイルをコピーして、props を流し込むだけ。
:LiAlertCircle: 注意事項
- 依存パッケージを忘れず追加