A-2 実装内容まとめ:
バックエンド: POST /api/plans/bulk_update/ API(field_ids, year, crop, variety を受けて一括設定) フロントエンド: チェックボックス列、全選択/個別選択、一括操作バー(作物・品種セレクタ + 確認ダイアログ)
This commit is contained in:
@@ -215,9 +215,8 @@ Variety (品種マスタ)
|
||||
4. **パフォーマンス**: N+1問題が一部存在(現状は問題ないが、データ増加時に対応必要)
|
||||
### 🔜 次の実装タスク(優先順)
|
||||
|
||||
1. **A-2**: チェックボックス・一括操作
|
||||
2. **A-1**: ダッシュボード画面
|
||||
3. **A-7**: 検索・フィルタ
|
||||
1. **A-1**: ダッシュボード画面
|
||||
2. **A-7**: 検索・フィルタ
|
||||
|
||||
詳細は `document/06_ドキュメントvs実装_差異レポート.md` を参照
|
||||
|
||||
|
||||
@@ -79,6 +79,44 @@ class PlanViewSet(viewsets.ModelViewSet):
|
||||
|
||||
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'])
|
||||
def get_crops_with_varieties(self, request):
|
||||
crops = Crop.objects.prefetch_related('varieties').all()
|
||||
|
||||
@@ -19,14 +19,13 @@
|
||||
|
||||
---
|
||||
|
||||
### A-2: チェックボックスによる一括操作
|
||||
### ~~A-2: チェックボックスによる一括操作~~ ✅ 対応済み
|
||||
|
||||
- **ドキュメント**: 画面設計書 画面3 - 各行にチェックボックス、複数選択→一括割当
|
||||
- **実装**: チェックボックスなし、一括操作UI なし
|
||||
- **補足**: Backend には `POST /api/plans/bulk_update/` APIが既に存在する
|
||||
- **状態**: 🔜 未着手
|
||||
|
||||
**対応方針**: 利便性向上の為必要です。
|
||||
- **対応内容**:
|
||||
- バックエンド: `POST /api/plans/bulk_update/` API追加(field_ids, year, crop, variety を受けて一括 update_or_create)
|
||||
- フロントエンド: 作付け計画画面(/allocation)にチェックボックス列追加、全選択/個別選択
|
||||
- 一括操作バー: 選択件数表示、作物・品種セレクタ、「一括設定」ボタン、確認ダイアログ付き
|
||||
- **対応日**: 2026-02-19
|
||||
|
||||
---
|
||||
|
||||
@@ -226,7 +225,7 @@
|
||||
| カテゴリ | 項目 | 状態 |
|
||||
|---------|------|------|
|
||||
| A-1 | ダッシュボード画面 | 🔜 未着手 |
|
||||
| A-2 | チェックボックス一括操作 | 🔜 未着手 |
|
||||
| A-2 | チェックボックス一括操作 | ✅ 完了 |
|
||||
| A-3 | 前年度コピーボタン | ✅ 完了 |
|
||||
| A-4 | 品種インライン追加・削除 | ✅ 完了 |
|
||||
| A-5 | PDFプレビュー | ✅ 完了 |
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState, useEffect, useMemo } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import { Field, Crop, Plan } from '@/types';
|
||||
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 {
|
||||
cropId: number;
|
||||
@@ -35,6 +35,10 @@ export default function AllocationPage() {
|
||||
const [newVarietyName, setNewVarietyName] = useState('');
|
||||
const [showVarietyManager, setShowVarietyManager] = useState(false);
|
||||
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(() => {
|
||||
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 fromYear = year - 1;
|
||||
if (!confirm(`${fromYear}年度の作付け計画を${year}年度にコピーします。\n既に設定済みの圃場はスキップされます。\n実行しますか?`)) return;
|
||||
@@ -534,11 +579,63 @@ export default function AllocationPage() {
|
||||
</p>
|
||||
</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="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<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' && (
|
||||
<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;
|
||||
|
||||
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' && (
|
||||
<td className="px-2 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center gap-1">
|
||||
|
||||
Reference in New Issue
Block a user