施肥計画編集画面に四捨五入トグル機能を追加

- calcMatrix(計算値)+ adjusted(確定値)の2層構成に変更
- 肥料列ヘッダーに ≈(青)/ ↩(琥珀)トグルボタンを追加
- 四捨五入後は元の計算値をグレーで参照表示
- docker-compose.yml に WATCHPACK_POLLING=true を追加(Windowsホットリロード修正)
- マスタードキュメント(文書13)を新 UI 仕様に更新

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Akira
2026-03-01 13:40:38 +09:00
parent 8ac3a00737
commit cfd67e0d55
3 changed files with 177 additions and 65 deletions

View File

@@ -48,6 +48,7 @@ services:
container_name: keinasystem_frontend container_name: keinasystem_frontend
environment: environment:
NEXT_PUBLIC_API_URL: http://localhost:8000 NEXT_PUBLIC_API_URL: http://localhost:8000
WATCHPACK_POLLING: "true"
ports: ports:
- "3000:3000" - "3000:3000"
volumes: volumes:

View File

@@ -289,27 +289,40 @@ GET /api/plans/crops/
2. **圃場選択**: 品種選択後に候補圃場が自動取得(`candidate_fields` API。チップ形式で追加/解除。候補外の圃場は「全圃場から追加」で手動選択 2. **圃場選択**: 品種選択後に候補圃場が自動取得(`candidate_fields` API。チップ形式で追加/解除。候補外の圃場は「全圃場から追加」で手動選択
3. **肥料追加**: 「+肥料を追加」で肥料マスタからドロップダウン選択 3. **肥料追加**: 「+肥料を追加」で肥料マスタからドロップダウン選択
4. **自動計算**: 各肥料に方式per_tan/even/nitrogenとパラメータを設定し「計算」ボタンでマトリクスに反映上書き確認あり 4. **自動計算**: 各肥料に方式per_tan/even/nitrogenとパラメータを設定し「計算」ボタンでマトリクスに反映上書き確認あり
5. **手動調整**: マトリクス表のセルを直接編集 5. **四捨五入**: 肥料列ヘッダーの `≈` ボタン(青)を押すと袋数を整数に丸める。押した後は `↩` ボタン(琥珀色)に変わり、押すと元の計算値に戻る
6. **保存**: 「保存」ボタンで entries を一括送信 6. **手動調整**: マトリクス表のセルを直接編集
7. **保存**: 「保存」ボタンで entries を一括送信
#### マトリクスの表示仕様
- 自動計算直後: セルに計算値(小数)がそのまま表示される(編集可)
- `≈` ボタン押下後: セルの入力値が整数に丸められ、元の計算値が薄いグレーで参照表示される
- `↩` ボタン押下: 整数値を破棄し、元の計算値に戻る(参照グレー表示も消える)
- 編集中に計算を再実行すると、その肥料列の `adjusted``roundedColumns` がリセットされる
#### State 構成 #### State 構成
```typescript ```typescript
// 基本情報 // 基本情報
const [planName, setPlanName] = useState('') const [name, setName] = useState('')
const [planYear, setPlanYear] = useState(currentYear) const [year, setYear] = useState(currentYear)
const [varietyId, setVarietyId] = useState<number | ''>('') const [varietyId, setVarietyId] = useState<number | ''>('')
// 圃場・肥料 // 圃場・肥料
const [selectedFields, setSelectedFields] = useState<FieldInfo[]>([]) const [selectedFields, setSelectedFields] = useState<Field[]>([])
const [planFertilizers, setPlanFertilizers] = useState<Fertilizer[]>([]) const [planFertilizers, setPlanFertilizers] = useState<Fertilizer[]>([])
// 自動計算設定(肥料ごと) // 自動計算設定(肥料ごと)
const [calcSettings, setCalcSettings] = useState<CalcSetting[]>([]) const [calcSettings, setCalcSettings] = useState<CalcSetting[]>([])
// CalcSetting: { fertilizer_id, method: 'per_tan'|'even'|'nitrogen', param: string } // CalcSetting: { fertilizer_id, method: 'per_tan'|'even'|'nitrogen', param: string }
// マトリクスfieldId → fertilizerId → 袋数文字列) // マトリクス 2層構成fieldId → fertilizerId → 袋数文字列)
const [matrix, setMatrix] = useState<Record<number, Record<number, string>>>({}) const [calcMatrix, setCalcMatrix] = useState<Matrix>({}) // 自動計算値(参照用・変更不可表示)
const [adjusted, setAdjusted] = useState<Matrix>({}) // ユーザー確定値(保存対象)
const [roundedColumns, setRoundedColumns] = useState<Set<number>>(new Set()) // ↩ トグル管理
// effectiveValue(fieldId, fertId) で保存値を決定:
// adjusted[field][fert] があればそれを優先、なければ calcMatrix[field][fert]
``` ```
--- ---
@@ -425,6 +438,14 @@ plans アプリの `DefaultRouter(r'', PlanViewSet)` が `plans/get-crops-with-v
PUT 時は entries を全削除→再作成する「全置換」方式。 PUT 時は entries を全削除→再作成する「全置換」方式。
部分更新は非対応PATCH でも entries がある場合は全置換)。 部分更新は非対応PATCH でも entries がある場合は全置換)。
### Next.js ホットリロードが効かない問題Windows + Docker
Windows 環境では Docker ボリュームマウント経由のファイル変更が inotify で検知されず、
フロントエンドのホットリロードが動かない。
**対策**: `docker-compose.yml` の frontend 環境変数に `WATCHPACK_POLLING: "true"` を追加。
ポーリング方式に切り替えることでファイル変更を検知できるようになる。
--- ---
## 将来の拡張(スコープ外) ## 将来の拡張(スコープ外)

View File

@@ -15,7 +15,7 @@ interface CalcSetting {
param: string; param: string;
} }
// matrix: field_id → fertilizer_id → bags // field_id → fertilizer_id → bags (string)
type Matrix = Record<number, Record<number, string>>; type Matrix = Record<number, Record<number, string>>;
const METHOD_LABELS: Record<CalcMethod, string> = { const METHOD_LABELS: Record<CalcMethod, string> = {
@@ -56,8 +56,13 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
const [calcSettings, setCalcSettings] = useState<CalcSetting[]>([]); const [calcSettings, setCalcSettings] = useState<CalcSetting[]>([]);
const [showFertPicker, setShowFertPicker] = useState(false); const [showFertPicker, setShowFertPicker] = useState(false);
// ─── マトリクス(袋数) // ─── マトリクス
const [matrix, setMatrix] = useState<Matrix>({}); // calcMatrix: 自動計算の結果(参照用・変更不可の表示値)
// adjusted: ユーザーが最終確定した値(保存対象)
// roundedColumns: 四捨五入済みの肥料列ID↩ トグル用)
const [calcMatrix, setCalcMatrix] = useState<Matrix>({});
const [adjusted, setAdjusted] = useState<Matrix>({});
const [roundedColumns, setRoundedColumns] = useState<Set<number>>(new Set());
const [loading, setLoading] = useState(!isNew); const [loading, setLoading] = useState(!isNew);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -82,7 +87,6 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
setYear(plan.year); setYear(plan.year);
setVarietyId(plan.variety); setVarietyId(plan.variety);
// エントリからマトリクス・肥料リストを復元
const fertIds = Array.from(new Set(plan.entries.map((e) => e.fertilizer))); const fertIds = Array.from(new Set(plan.entries.map((e) => e.fertilizer)));
const ferts = fertsRes.data.filter((f: Fertilizer) => fertIds.includes(f.id)); const ferts = fertsRes.data.filter((f: Fertilizer) => fertIds.includes(f.id));
setPlanFertilizers(ferts); setPlanFertilizers(ferts);
@@ -92,12 +96,13 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
const fields = fieldsRes.data.filter((f: Field) => fieldIds.includes(f.id)); const fields = fieldsRes.data.filter((f: Field) => fieldIds.includes(f.id));
setSelectedFields(fields); setSelectedFields(fields);
const newMatrix: Matrix = {}; // 保存済みの値は adjusted に復元calc値はなし
const newAdjusted: Matrix = {};
plan.entries.forEach((e) => { plan.entries.forEach((e) => {
if (!newMatrix[e.field]) newMatrix[e.field] = {}; if (!newAdjusted[e.field]) newAdjusted[e.field] = {};
newMatrix[e.field][e.fertilizer] = String(e.bags); newAdjusted[e.field][e.fertilizer] = String(e.bags);
}); });
setMatrix(newMatrix); setAdjusted(newAdjusted);
} }
} catch (e: unknown) { } catch (e: unknown) {
const err = e as { response?: { status?: number; data?: unknown } }; const err = e as { response?: { status?: number; data?: unknown } };
@@ -116,7 +121,6 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
const res = await api.get(`/fertilizer/candidate_fields/?year=${y}&variety_id=${vId}`); const res = await api.get(`/fertilizer/candidate_fields/?year=${y}&variety_id=${vId}`);
const candidates: Field[] = res.data; const candidates: Field[] = res.data;
setCandidateFields(candidates); setCandidateFields(candidates);
// 既存選択を候補で上書き(新規作成時のみ)
if (isNew) setSelectedFields(candidates); if (isNew) setSelectedFields(candidates);
} catch (e) { } catch (e) {
console.error(e); console.error(e);
@@ -129,7 +133,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
} }
}, [varietyId, year, fetchCandidates]); }, [varietyId, year, fetchCandidates]);
// ─── 肥料追加 // ─── 肥料追加・削除
const addFertilizer = (fert: Fertilizer) => { const addFertilizer = (fert: Fertilizer) => {
if (planFertilizers.find((f) => f.id === fert.id)) return; if (planFertilizers.find((f) => f.id === fert.id)) return;
setPlanFertilizers((prev) => [...prev, fert]); setPlanFertilizers((prev) => [...prev, fert]);
@@ -141,15 +145,18 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
if (!confirm('この肥料を計画から削除しますか?')) return; if (!confirm('この肥料を計画から削除しますか?')) return;
setPlanFertilizers((prev) => prev.filter((f) => f.id !== id)); setPlanFertilizers((prev) => prev.filter((f) => f.id !== id));
setCalcSettings((prev) => prev.filter((s) => s.fertilizer_id !== id)); setCalcSettings((prev) => prev.filter((s) => s.fertilizer_id !== id));
setMatrix((prev) => { const dropCol = (m: Matrix): Matrix => {
const next = { ...prev }; const next = { ...m };
Object.keys(next).forEach((fid) => { Object.keys(next).forEach((fid) => {
const row = { ...next[Number(fid)] }; const row = { ...next[Number(fid)] };
delete row[id]; delete row[id];
next[Number(fid)] = row; next[Number(fid)] = row;
}); });
return next; return next;
}); };
setCalcMatrix(dropCol);
setAdjusted(dropCol);
setRoundedColumns((prev) => { const next = new Set(prev); next.delete(id); return next; });
}; };
// ─── 圃場追加・削除 // ─── 圃場追加・削除
@@ -161,11 +168,8 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
const removeField = (id: number) => { const removeField = (id: number) => {
setSelectedFields((prev) => prev.filter((f) => f.id !== id)); setSelectedFields((prev) => prev.filter((f) => f.id !== id));
setMatrix((prev) => { setCalcMatrix((prev) => { const next = { ...prev }; delete next[id]; return next; });
const next = { ...prev }; setAdjusted((prev) => { const next = { ...prev }; delete next[id]; return next; });
delete next[id];
return next;
});
}; };
// ─── 自動計算 // ─── 自動計算
@@ -181,7 +185,9 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
field_ids: selectedFields.map((f) => f.id), field_ids: selectedFields.map((f) => f.id),
}); });
const results: { field_id: number; bags: number }[] = res.data; const results: { field_id: number; bags: number }[] = res.data;
setMatrix((prev) => {
// calc値を更新
setCalcMatrix((prev) => {
const next = { ...prev }; const next = { ...prev };
results.forEach(({ field_id, bags }) => { results.forEach(({ field_id, bags }) => {
if (!next[field_id]) next[field_id] = {}; if (!next[field_id]) next[field_id] = {};
@@ -189,6 +195,20 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
}); });
return next; return next;
}); });
// adjusted と丸め状態をリセット(新しい計算結果を再丸めさせる)
setAdjusted((prev) => {
const next = { ...prev };
results.forEach(({ field_id }) => {
if (next[field_id]) {
const row = { ...next[field_id] };
delete row[setting.fertilizer_id];
next[field_id] = row;
}
});
return next;
});
setRoundedColumns((prev) => { const next = new Set(prev); next.delete(setting.fertilizer_id); return next; });
} catch (e: unknown) { } catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } }; const err = e as { response?: { data?: { error?: string } } };
alert(err.response?.data?.error ?? '計算に失敗しました'); alert(err.response?.data?.error ?? '計算に失敗しました');
@@ -201,9 +221,9 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
); );
}; };
// ─── セル更新 // ─── セル更新adjusted を更新)
const updateCell = (fieldId: number, fertId: number, value: string) => { const updateCell = (fieldId: number, fertId: number, value: string) => {
setMatrix((prev) => { setAdjusted((prev) => {
const next = { ...prev }; const next = { ...prev };
if (!next[fieldId]) next[fieldId] = {}; if (!next[fieldId]) next[fieldId] = {};
next[fieldId][fertId] = value; next[fieldId][fertId] = value;
@@ -211,7 +231,60 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
}); });
}; };
// ─── 保存 // ─── 列単位で四捨五入 / 元に戻す(トグル)
const roundColumn = (fertId: number) => {
if (roundedColumns.has(fertId)) {
// 元に戻す: adjusted からこの列を削除 → calc値が再び表示される
setAdjusted((prev) => {
const next = { ...prev };
selectedFields.forEach((field) => {
if (next[field.id]) {
const row = { ...next[field.id] };
delete row[fertId];
next[field.id] = row;
}
});
return next;
});
setRoundedColumns((prev) => { const next = new Set(prev); next.delete(fertId); return next; });
} else {
// 四捨五入: calc値を整数に丸めて adjusted に書き込む
setAdjusted((prev) => {
const next = { ...prev };
selectedFields.forEach((field) => {
const calc = calcMatrix[field.id]?.[fertId];
if (calc !== undefined && calc !== '') {
const v = parseFloat(calc);
if (!isNaN(v)) {
if (!next[field.id]) next[field.id] = {};
next[field.id][fertId] = String(Math.round(v));
}
}
});
return next;
});
setRoundedColumns((prev) => { const next = new Set(prev); next.add(fertId); return next; });
}
};
// ─── 集計adjusted 優先、なければ calc 値)
const effectiveValue = (fieldId: number, fertId: number): number => {
const adj = adjusted[fieldId]?.[fertId];
const calc = calcMatrix[fieldId]?.[fertId];
const raw = adj !== undefined && adj !== '' ? adj : calc;
const v = parseFloat(raw ?? '0');
return isNaN(v) ? 0 : v;
};
const rowTotal = (fieldId: number) =>
planFertilizers.reduce((sum, f) => sum + effectiveValue(fieldId, f.id), 0);
const colTotal = (fertId: number) =>
selectedFields.reduce((sum, f) => sum + effectiveValue(f.id, fertId), 0);
const grandTotal = planFertilizers.reduce((sum, f) => sum + colTotal(f.id), 0);
// ─── 保存adjusted 優先、なければ calc 値を使用)
const handleSave = async () => { const handleSave = async () => {
if (!name.trim()) return alert('計画名を入力してください'); if (!name.trim()) return alert('計画名を入力してください');
if (!varietyId) return alert('品種を選択してください'); if (!varietyId) return alert('品種を選択してください');
@@ -220,9 +293,12 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
const entries: { field_id: number; fertilizer_id: number; bags: number }[] = []; const entries: { field_id: number; fertilizer_id: number; bags: number }[] = [];
selectedFields.forEach((field) => { selectedFields.forEach((field) => {
planFertilizers.forEach((fert) => { planFertilizers.forEach((fert) => {
const v = matrix[field.id]?.[fert.id]; const adj = adjusted[field.id]?.[fert.id];
if (v && parseFloat(v) > 0) { const calc = calcMatrix[field.id]?.[fert.id];
entries.push({ field_id: field.id, fertilizer_id: fert.id, bags: parseFloat(v) }); const raw = adj !== undefined && adj !== '' ? adj : calc;
if (raw) {
const v = parseFloat(raw);
if (v > 0) entries.push({ field_id: field.id, fertilizer_id: fert.id, bags: v });
} }
}); });
}); });
@@ -261,21 +337,6 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
} }
}; };
// ─── 集計
const rowTotal = (fieldId: number) =>
planFertilizers.reduce((sum, f) => {
const v = parseFloat(matrix[fieldId]?.[f.id] ?? '0') || 0;
return sum + v;
}, 0);
const colTotal = (fertId: number) =>
selectedFields.reduce((sum, f) => {
const v = parseFloat(matrix[f.id]?.[fertId] ?? '0') || 0;
return sum + v;
}, 0);
const grandTotal = planFertilizers.reduce((sum, f) => sum + colTotal(f.id), 0);
const years = Array.from({ length: 5 }, (_, i) => currentYear + 1 - i); const years = Array.from({ length: 5 }, (_, i) => currentYear + 1 - i);
const availableFerts = allFertilizers.filter((f) => !planFertilizers.find((pf) => pf.id === f.id)); const availableFerts = allFertilizers.filter((f) => !planFertilizers.find((pf) => pf.id === f.id));
const unselectedFields = allFields.filter((f) => !selectedFields.find((sf) => sf.id === f.id)); const unselectedFields = allFields.filter((f) => !selectedFields.find((sf) => sf.id === f.id));
@@ -469,12 +530,28 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
<tr> <tr>
<th className="text-left px-4 py-3 border border-gray-200 font-medium text-gray-700 whitespace-nowrap"></th> <th className="text-left px-4 py-3 border border-gray-200 font-medium text-gray-700 whitespace-nowrap"></th>
<th className="text-right px-3 py-3 border border-gray-200 font-medium text-gray-700 whitespace-nowrap">()</th> <th className="text-right px-3 py-3 border border-gray-200 font-medium text-gray-700 whitespace-nowrap">()</th>
{planFertilizers.map((f) => ( {planFertilizers.map((f) => {
<th key={f.id} className="text-center px-3 py-3 border border-gray-200 font-medium text-gray-700 whitespace-nowrap"> const isRounded = roundedColumns.has(f.id);
{f.name} return (
<span className="block text-xs font-normal text-gray-400"></span> <th key={f.id} className="text-center px-3 py-2 border border-gray-200 font-medium text-gray-700 whitespace-nowrap">
</th> {f.name}
))} <span className="flex items-center justify-center gap-1.5 text-xs font-normal text-gray-400 mt-0.5">
<button
onClick={() => roundColumn(f.id)}
className={`inline-flex items-center justify-center w-5 h-5 rounded font-bold leading-none ${
isRounded
? 'bg-amber-100 text-amber-600 hover:bg-amber-200'
: 'bg-blue-100 text-blue-500 hover:bg-blue-200'
}`}
title={isRounded ? '元の計算値に戻す' : '四捨五入して整数に丸める'}
>
{isRounded ? '↩' : '≈'}
</button>
</span>
</th>
);
})}
<th className="text-right px-3 py-3 border border-gray-200 font-medium text-gray-700 whitespace-nowrap"></th> <th className="text-right px-3 py-3 border border-gray-200 font-medium text-gray-700 whitespace-nowrap"></th>
</tr> </tr>
</thead> </thead>
@@ -483,18 +560,31 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
<tr key={field.id} className="hover:bg-gray-50"> <tr key={field.id} className="hover:bg-gray-50">
<td className="px-4 py-2 border border-gray-200 whitespace-nowrap">{field.name}</td> <td className="px-4 py-2 border border-gray-200 whitespace-nowrap">{field.name}</td>
<td className="px-3 py-2 border border-gray-200 text-right text-gray-600">{field.area_tan}</td> <td className="px-3 py-2 border border-gray-200 text-right text-gray-600">{field.area_tan}</td>
{planFertilizers.map((fert) => ( {planFertilizers.map((fert) => {
<td key={fert.id} className="px-2 py-1 border border-gray-200"> const calcVal = calcMatrix[field.id]?.[fert.id];
<input const adjVal = adjusted[field.id]?.[fert.id];
type="number" // adjusted が設定されているときだけ灰色参照を表示(丸め後)
step="0.01" const showRef = adjVal !== undefined && calcVal !== undefined;
className="w-full text-right border-0 bg-transparent focus:outline-none focus:ring-1 focus:ring-green-400 rounded px-1 py-1" // 入力欄: adjusted → calc値 → 空
value={matrix[field.id]?.[fert.id] ?? ''} const inputValue = adjVal !== undefined ? adjVal : (calcVal ?? '');
onChange={(e) => updateCell(field.id, fert.id, e.target.value)} return (
placeholder="-" <td key={fert.id} className="px-2 py-1 border border-gray-200">
/> <div className="flex items-center justify-end gap-1.5">
</td> {showRef && (
))} <span className="text-gray-300 text-xs tabular-nums">{calcVal}</span>
)}
<input
type="number"
step="0.1"
className="w-14 text-right border border-gray-200 rounded bg-white focus:outline-none focus:ring-1 focus:ring-green-400 px-1 py-0.5 text-sm"
value={inputValue}
onChange={(e) => updateCell(field.id, fert.id, e.target.value)}
placeholder="-"
/>
</div>
</td>
);
})}
<td className="px-3 py-2 border border-gray-200 text-right font-medium"> <td className="px-3 py-2 border border-gray-200 text-right font-medium">
{rowTotal(field.id) > 0 ? rowTotal(field.id).toFixed(2) : '-'} {rowTotal(field.id) > 0 ? rowTotal(field.id).toFixed(2) : '-'}
</td> </td>