A-2 実装内容まとめ:

バックエンド: POST /api/plans/bulk_update/ API(field_ids, year, crop, variety を受けて一括設定)
フロントエンド: チェックボックス列、全選択/個別選択、一括操作バー(作物・品種セレクタ + 確認ダイアログ)
This commit is contained in:
Akira
2026-02-19 12:29:54 +09:00
parent 8b5e0fc66e
commit cce119b1a8
4 changed files with 154 additions and 13 deletions

View File

@@ -215,9 +215,8 @@ Variety (品種マスタ)
4. **パフォーマンス**: N+1問題が一部存在現状は問題ないが、データ増加時に対応必要 4. **パフォーマンス**: N+1問題が一部存在現状は問題ないが、データ増加時に対応必要
### 🔜 次の実装タスク(優先順) ### 🔜 次の実装タスク(優先順)
1. **A-2**: チェックボックス・一括操作 1. **A-1**: ダッシュボード画面
2. **A-1**: ダッシュボード画面 2. **A-7**: 検索・フィルタ
3. **A-7**: 検索・フィルタ
詳細は `document/06_ドキュメントvs実装_差異レポート.md` を参照 詳細は `document/06_ドキュメントvs実装_差異レポート.md` を参照

View File

@@ -79,6 +79,44 @@ class PlanViewSet(viewsets.ModelViewSet):
return Response({'message': f'Copied {len(new_plans)} plans from {from_year} to {to_year}'}) return Response({'message': f'Copied {len(new_plans)} plans from {from_year} to {to_year}'})
@action(detail=False, methods=['post'])
def bulk_update(self, request):
"""複数圃場の作付け計画を一括更新"""
field_ids = request.data.get('field_ids', [])
year = request.data.get('year')
crop_id = request.data.get('crop')
variety_id = request.data.get('variety')
if not field_ids or not year or not crop_id:
return Response({'error': 'field_ids, year, crop are required'}, status=status.HTTP_400_BAD_REQUEST)
try:
crop = Crop.objects.get(id=crop_id)
except Crop.DoesNotExist:
return Response({'error': 'Crop not found'}, status=status.HTTP_400_BAD_REQUEST)
variety = None
if variety_id:
try:
variety = Variety.objects.get(id=variety_id)
except Variety.DoesNotExist:
pass
updated = 0
created = 0
for field_id in field_ids:
plan, was_created = Plan.objects.update_or_create(
field_id=field_id,
year=year,
defaults={'crop': crop, 'variety': variety}
)
if was_created:
created += 1
else:
updated += 1
return Response({'created': created, 'updated': updated, 'total': created + updated})
@action(detail=False, methods=['get']) @action(detail=False, methods=['get'])
def get_crops_with_varieties(self, request): def get_crops_with_varieties(self, request):
crops = Crop.objects.prefetch_related('varieties').all() crops = Crop.objects.prefetch_related('varieties').all()

View File

@@ -19,14 +19,13 @@
--- ---
### A-2: チェックボックスによる一括操作 ### ~~A-2: チェックボックスによる一括操作~~ ✅ 対応済み
- **ドキュメント**: 画面設計書 画面3 - 各行にチェックボックス、複数選択→一括割当 - **対応内容**:
- **実装**: チェックボックスなし、一括操作UI なし - バックエンド: `POST /api/plans/bulk_update/` API追加field_ids, year, crop, variety を受けて一括 update_or_create
- **補足**: Backend には `POST /api/plans/bulk_update/` APIが既に存在する - フロントエンド: 作付け計画画面(/allocationにチェックボックス列追加、全選択/個別選択
- **状態**: 🔜 未着手 - 一括操作バー: 選択件数表示、作物・品種セレクタ、「一括設定」ボタン、確認ダイアログ付き
- **対応日**: 2026-02-19
**対応方針**: 利便性向上の為必要です。
--- ---
@@ -226,7 +225,7 @@
| カテゴリ | 項目 | 状態 | | カテゴリ | 項目 | 状態 |
|---------|------|------| |---------|------|------|
| A-1 | ダッシュボード画面 | 🔜 未着手 | | A-1 | ダッシュボード画面 | 🔜 未着手 |
| A-2 | チェックボックス一括操作 | 🔜 未着手 | | A-2 | チェックボックス一括操作 | ✅ 完了 |
| A-3 | 前年度コピーボタン | ✅ 完了 | | A-3 | 前年度コピーボタン | ✅ 完了 |
| A-4 | 品種インライン追加・削除 | ✅ 完了 | | A-4 | 品種インライン追加・削除 | ✅ 完了 |
| A-5 | PDFプレビュー | ✅ 完了 | | A-5 | PDFプレビュー | ✅ 完了 |

View File

@@ -4,7 +4,7 @@ import { useState, useEffect, useMemo } from 'react';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { Field, Crop, Plan } from '@/types'; import { Field, Crop, Plan } from '@/types';
import Navbar from '@/components/Navbar'; import Navbar from '@/components/Navbar';
import { Menu, X, BarChart3, ChevronRight, ChevronDown, ArrowUp, ArrowDown, Copy, Settings, Trash2 } from 'lucide-react'; import { Menu, X, BarChart3, ChevronRight, ChevronDown, ArrowUp, ArrowDown, Copy, Settings, Trash2, CheckSquare } from 'lucide-react';
interface SummaryItem { interface SummaryItem {
cropId: number; cropId: number;
@@ -35,6 +35,10 @@ export default function AllocationPage() {
const [newVarietyName, setNewVarietyName] = useState(''); const [newVarietyName, setNewVarietyName] = useState('');
const [showVarietyManager, setShowVarietyManager] = useState(false); const [showVarietyManager, setShowVarietyManager] = useState(false);
const [managerCropId, setManagerCropId] = useState<number | null>(null); const [managerCropId, setManagerCropId] = useState<number | null>(null);
const [selectedFields, setSelectedFields] = useState<Set<number>>(new Set());
const [bulkCropId, setBulkCropId] = useState<number | 0>(0);
const [bulkVarietyId, setBulkVarietyId] = useState<number | 0>(0);
const [bulkUpdating, setBulkUpdating] = useState(false);
useEffect(() => { useEffect(() => {
fetchData(); fetchData();
@@ -321,6 +325,47 @@ export default function AllocationPage() {
} }
}; };
const toggleFieldSelection = (fieldId: number) => {
setSelectedFields((prev) => {
const next = new Set(prev);
if (next.has(fieldId)) next.delete(fieldId);
else next.add(fieldId);
return next;
});
};
const toggleAllFields = () => {
if (selectedFields.size === sortedFields.length) {
setSelectedFields(new Set());
} else {
setSelectedFields(new Set(sortedFields.map((f) => f.id)));
}
};
const handleBulkUpdate = async () => {
if (selectedFields.size === 0 || !bulkCropId) return;
if (!confirm(`選択した${selectedFields.size}件の圃場に一括で作物・品種を設定します。\n既存の設定は上書きされます。実行しますか`)) return;
setBulkUpdating(true);
try {
await api.post('/plans/bulk_update/', {
field_ids: Array.from(selectedFields),
year,
crop: bulkCropId,
variety: bulkVarietyId || null,
});
setSelectedFields(new Set());
setBulkCropId(0);
setBulkVarietyId(0);
await fetchData();
} catch (error) {
console.error('Bulk update failed:', error);
alert('一括更新に失敗しました');
} finally {
setBulkUpdating(false);
}
};
const handleCopyFromPreviousYear = async () => { const handleCopyFromPreviousYear = async () => {
const fromYear = year - 1; const fromYear = year - 1;
if (!confirm(`${fromYear}年度の作付け計画を${year}年度にコピーします。\n既に設定済みの圃場はスキップされます。\n実行しますか`)) return; if (!confirm(`${fromYear}年度の作付け計画を${year}年度にコピーします。\n既に設定済みの圃場はスキップされます。\n実行しますか`)) return;
@@ -534,11 +579,63 @@ export default function AllocationPage() {
</p> </p>
</div> </div>
{selectedFields.size > 0 && (
<div className="mb-4 p-3 bg-green-50 border border-green-200 rounded-md flex items-center gap-3 flex-wrap">
<span className="text-sm font-medium text-green-800">
<CheckSquare className="h-4 w-4 inline mr-1" />
{selectedFields.size}
</span>
<select
value={bulkCropId || ''}
onChange={(e) => { setBulkCropId(parseInt(e.target.value) || 0); setBulkVarietyId(0); }}
className="px-2 py-1.5 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
>
<option value=""></option>
{crops.map((crop) => (
<option key={crop.id} value={crop.id}>{crop.name}</option>
))}
</select>
{bulkCropId > 0 && (
<select
value={bulkVarietyId || ''}
onChange={(e) => setBulkVarietyId(parseInt(e.target.value) || 0)}
className="px-2 py-1.5 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
>
<option value=""></option>
{getVarietiesForCrop(bulkCropId).map((v) => (
<option key={v.id} value={v.id}>{v.name}</option>
))}
</select>
)}
<button
onClick={handleBulkUpdate}
disabled={!bulkCropId || bulkUpdating}
className="px-3 py-1.5 bg-green-600 text-white rounded-md text-sm hover:bg-green-700 disabled:opacity-50"
>
{bulkUpdating ? '更新中...' : '一括設定'}
</button>
<button
onClick={() => setSelectedFields(new Set())}
className="px-3 py-1.5 text-gray-600 hover:text-gray-800 text-sm"
>
</button>
</div>
)}
<div className="bg-white rounded-lg shadow overflow-hidden"> <div className="bg-white rounded-lg shadow overflow-hidden">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200"> <table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50"> <thead className="bg-gray-50">
<tr> <tr>
<th className="px-2 py-3 w-10">
<input
type="checkbox"
checked={selectedFields.size === sortedFields.length && sortedFields.length > 0}
onChange={toggleAllFields}
className="rounded border-gray-300 text-green-600 focus:ring-green-500"
/>
</th>
{sortType === 'custom' && ( {sortType === 'custom' && (
<th className="px-2 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-20"> <th className="px-2 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-20">
@@ -571,7 +668,15 @@ export default function AllocationPage() {
const selectedVarietyId = plan?.variety || 0; const selectedVarietyId = plan?.variety || 0;
return ( return (
<tr key={field.id} className="hover:bg-gray-50"> <tr key={field.id} className={`hover:bg-gray-50 ${selectedFields.has(field.id) ? 'bg-green-50' : ''}`}>
<td className="px-2 py-4 w-10">
<input
type="checkbox"
checked={selectedFields.has(field.id)}
onChange={() => toggleFieldSelection(field.id)}
className="rounded border-gray-300 text-green-600 focus:ring-green-500"
/>
</td>
{sortType === 'custom' && ( {sortType === 'custom' && (
<td className="px-2 py-4 whitespace-nowrap"> <td className="px-2 py-4 whitespace-nowrap">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">