A-7(検索・フィルタ)の実装が完了しました。

実装内容:

テキスト検索: 圃場名・住所で部分一致検索(リアルタイムフィルタリング、検索アイコン付き)
作物フィルタ: ドロップダウンで特定作物に絞り込み
未割当トグル: チェックボックスで未割当の圃場のみ表示
件数表示: フィルタ適用中は 5/39件 のように表示
チェックボックス全選択もフィルタ結果に連動
http://localhost:3000/allocation で確認できます。
This commit is contained in:
Akira
2026-02-19 13:11:13 +09:00
parent 4afe37968b
commit 6eb19f75b7
3 changed files with 94 additions and 16 deletions

View File

@@ -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, CheckSquare } from 'lucide-react';
import { Menu, X, BarChart3, ChevronRight, ChevronDown, ArrowUp, ArrowDown, Copy, Settings, Trash2, CheckSquare, Search } from 'lucide-react';
interface SummaryItem {
cropId: number;
@@ -39,6 +39,9 @@ export default function AllocationPage() {
const [bulkCropId, setBulkCropId] = useState<number | 0>(0);
const [bulkVarietyId, setBulkVarietyId] = useState<number | 0>(0);
const [bulkUpdating, setBulkUpdating] = useState(false);
const [searchText, setSearchText] = useState('');
const [filterCropId, setFilterCropId] = useState<number | 0>(0);
const [filterUnassigned, setFilterUnassigned] = useState(false);
useEffect(() => {
fetchData();
@@ -143,6 +146,35 @@ export default function AllocationPage() {
return sorted;
}, [fields, sortType, plans]);
const filteredFields = useMemo(() => {
let result = sortedFields;
if (searchText) {
const query = searchText.toLowerCase();
result = result.filter(
(f) =>
f.name.toLowerCase().includes(query) ||
(f.address && f.address.toLowerCase().includes(query))
);
}
if (filterCropId) {
result = result.filter((f) => {
const plan = plans.find((p) => p.field === f.id);
return plan?.crop === filterCropId;
});
}
if (filterUnassigned) {
result = result.filter((f) => {
const plan = plans.find((p) => p.field === f.id);
return !plan?.crop;
});
}
return result;
}, [sortedFields, searchText, filterCropId, filterUnassigned, plans]);
const groupOptions = useMemo(() => {
const groups = new Set<string>();
fields.forEach(f => {
@@ -335,10 +367,10 @@ export default function AllocationPage() {
};
const toggleAllFields = () => {
if (selectedFields.size === sortedFields.length) {
if (selectedFields.size === filteredFields.length) {
setSelectedFields(new Set());
} else {
setSelectedFields(new Set(sortedFields.map((f) => f.id)));
setSelectedFields(new Set(filteredFields.map((f) => f.id)));
}
};
@@ -565,7 +597,51 @@ export default function AllocationPage() {
</div>
</div>
{sortedFields.length === 0 ? (
{/* 検索・フィルタバー */}
<div className="mb-4 flex flex-wrap items-center gap-2">
<div className="relative flex-1 min-w-[200px] max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<input
type="text"
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
placeholder="圃場名・住所で検索..."
className="w-full pl-9 pr-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
/>
</div>
<select
value={filterCropId || ''}
onChange={(e) => { setFilterCropId(parseInt(e.target.value) || 0); setFilterUnassigned(false); }}
className="px-3 py-2 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>
<label className="flex items-center gap-1.5 text-sm text-gray-700 cursor-pointer">
<input
type="checkbox"
checked={filterUnassigned}
onChange={(e) => { setFilterUnassigned(e.target.checked); if (e.target.checked) setFilterCropId(0); }}
className="rounded border-gray-300 text-green-600 focus:ring-green-500"
/>
</label>
{(searchText || filterCropId || filterUnassigned) && (
<span className="text-sm text-gray-500">
{filteredFields.length}/{fields.length}
</span>
)}
</div>
{filteredFields.length === 0 && fields.length > 0 ? (
<div className="bg-white rounded-lg shadow p-8 text-center">
<p className="text-gray-500">
</p>
</div>
) : sortedFields.length === 0 ? (
<div className="bg-white rounded-lg shadow p-8 text-center">
<p className="text-gray-500">
@@ -631,7 +707,7 @@ export default function AllocationPage() {
<th className="px-2 py-3 w-10">
<input
type="checkbox"
checked={selectedFields.size === sortedFields.length && sortedFields.length > 0}
checked={selectedFields.size === filteredFields.length && filteredFields.length > 0}
onChange={toggleAllFields}
className="rounded border-gray-300 text-green-600 focus:ring-green-500"
/>
@@ -662,7 +738,7 @@ export default function AllocationPage() {
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{sortedFields.map((field, index) => {
{filteredFields.map((field, index) => {
const plan = getPlanForField(field.id);
const selectedCropId = plan?.crop || 0;
const selectedVarietyId = plan?.variety || 0;
@@ -690,7 +766,7 @@ export default function AllocationPage() {
</button>
<button
onClick={() => moveDown(index)}
disabled={index === sortedFields.length - 1 || saving === field.id}
disabled={index === filteredFields.length - 1 || saving === field.id}
className="p-1 hover:bg-gray-100 rounded disabled:opacity-30"
title="下へ移動"
>