栽培管理アプリを作ってます⑤:農薬マスタから防除記録まで、データの流れを作った

前回の記事では、農薬マスタの設計で詰まった話を書きました。

「同じ農薬でも対象病害虫によって倍率が違う」という問題を解決するために、マスタと詳細を分離する設計にしたところまで進んでいました。

今回は、その続きで、「農水省のデータをインポート」→「よく使う農薬をお気に入り登録」→「防除作業で実際に使う」という一連のデータの流れを実装した話です。

実装する中で、お気に入り機能のバグに遭遇したり、一括操作で404エラーが出たりと、いくつかトラブルもありましたが、すべて解決できました。

目次

全体の流れ

まず、実装した機能の全体像を説明します。

データの流れ:

  1. 農薬マスタにCSVインポート
  2. 農薬一覧で確認・編集
  3. よく使う農薬を「お気に入り」に登録
  4. ハウス管理で防除作業を記録(お気に入り農薬から選択)
  5. 対象病害虫を選ぶと、希釈倍率が自動表示
  6. 原液量を入力すると、散布量が自動計算
  7. データベースに保存

これで、「農薬データの管理」から「実際の作業記録」までが繋がりました。

ステップ1: 農薬マスタのCSVインポート

全体の流れ(ユーザー視点)

CSVインポート機能の使い方はシンプルです:

  1. ブラウザで /pesticides/import にアクセス
  2. 「ファイルを選択」ボタンを押す
  3. パソコンからCSVファイルを選ぶ
  4. 「インポート開始」ボタンを押す
  5. 画面に「処理中…」と表示される
  6. 完了したら「○○件の農薬を登録しました」と表示される

ファイル構成

CSVインポート機能は、3つのファイルで構成されています:

/pesticides/import/
  ├── page.tsx        ← サーバー側(認証チェック)
  ├── Client.tsx      ← クライアント側(UIとファイル処理)
  └── actions.ts      ← サーバー側(データベース登録)

それぞれの役割を見ていきます。

page.tsx(入り口)

まず、ユーザーがログインしているかチェックして、画面を表示します。

export default async function PesticideImportPage() {
  const supabase = await createClient();

  // ログインチェック
  const { data: { user } } = await supabase.auth.getUser();
  if (!user) {
    redirect('/login');
  }

  // ログインしていたら画面を表示
  return <PesticideImportClient />;
}

認証チェックだけして、Client.tsxに処理を渡します。

Client.tsx(画面とファイル処理)

次に、ファイル選択のUIと、Shift-JIS変換の処理を実装します。

export default function PesticideImportClient() {
  const [selectedFile, setSelectedFile] = useState<File | null>(null);
  const [isUploading, setIsUploading] = useState(false);

  const handleSubmit = async (e) => {
    e.preventDefault();
    setIsUploading(true);

    // ① ファイルを読み込む
    const arrayBuffer = await selectedFile.arrayBuffer();

    // ② Shift-JISをUTF-8に変換
    const decoder = new TextDecoder('shift-jis');
    const csvText = decoder.decode(arrayBuffer);

    // ③ UTF-8のファイルを作り直す
    const utf8Blob = new Blob([csvText], { type: 'text/csv;charset=utf-8' });
    const utf8File = new File([utf8Blob], selectedFile.name, { type: 'text/csv' });

    // ④ FormDataを作成してServer Actionに送る
    const formData = new FormData();
    formData.append('csv', utf8File);

    const result = await importPesticidesCSV(formData);

    if (result.success) {
      setMessage({ type: 'success', text: result.message });
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="file"
        accept=".csv"
        onChange={(e) => setSelectedFile(e.target.files?.[0])}
      />

      <button type="submit" disabled={isUploading || !selectedFile}>
        {isUploading ? '処理中...' : 'インポート開始'}
      </button>
    </form>
  );
}

なぜShift-JIS変換が必要?

農水省のCSVは「Shift-JIS」という古い文字コードで作られています。

そのまま読むと文字化けするので、UTF-8に変換してからサーバーに送ります。

変換しないと、「アスパラガス」が「繧「繧ケ繝代Λ繧ャ繧ケ」みたいに化けます。

actions.ts(データベース登録)

Server Actionでは、以下の処理を行います:

  1. CSVの中身を1行ずつ読む
  2. 同じ農薬名をまとめる
  3. データベースに保存

① CSVをパースする

function parseCSV(csvText) {
  const lines = csvText.split(/\r?\n/).filter((line) => line.trim());
  const dataLines = lines.slice(1); // ヘッダー行をスキップ

  const rows = [];
  for (const line of dataLines) {
    const columns = line.split(",");

    rows.push({
      registrationNumber: columns[0]?.trim(),
      pesticideType: columns[1]?.trim(),
      pesticideName: columns[2]?.trim(),
      purpose: columns[3]?.trim(),
      targetPest: columns[6]?.trim(),
      applicationMethod: columns[7]?.trim(),
      dilutionRatioMin: safeParseInt(columns[13]?.trim()),
      dilutionRatioMax: safeParseInt(columns[14]?.trim()),
      // ... 他のカラム
    });
  }

  return rows;
}

CSVを1行ずつ読んで、カラムごとに分割します。

結果はこんな感じ:

[
  { pesticideName: "ウララDF", targetPest: "アブラムシ類", ... },
  { pesticideName: "ウララDF", targetPest: "ネギアザミウマ", ... },
  { pesticideName: "モベントフロアブル", targetPest: "コナジラミ", ... },
]

同じ農薬名が何度も出てきますね。

② 農薬名でグループ化する

次に、同じ農薬名をまとめます。

function groupByPesticide(rows) {
  const grouped = new Map();

  for (const row of rows) {
    const key = row.pesticideName;

    // 初めて見る農薬名なら、新しく追加
    if (!grouped.has(key)) {
      grouped.set(key, {
        name: row.pesticideName,
        type: row.purpose,
        applications: [], // ここに対象病害虫ごとの詳細が入る
      });
    }

    // 適用詳細を追加
    const pesticide = grouped.get(key);
    pesticide.applications.push({
      targetPest: row.targetPest,
      applicationMethod: row.applicationMethod,
      dilutionRatioMin: row.dilutionRatioMin,
      dilutionRatioMax: row.dilutionRatioMax,
      // ... 他の情報
    });
  }

  return Array.from(grouped.values());
}

グループ化すると、こうなります:

[
  {
    name: "ウララDF",
    type: "殺虫剤",
    applications: [
      { targetPest: "アブラムシ類", dilutionRatioMin: 2000, ... },
      { targetPest: "ネギアザミウマ", dilutionRatioMin: 2000, ... }
    ]
  },
  {
    name: "モベントフロアブル",
    type: "殺虫剤",
    applications: [
      { targetPest: "コナジラミ", dilutionRatioMin: 1000, ... }
    ]
  }
]

1つの農薬に対して、複数の適用詳細がまとまりました。

③ データベースに保存する

最後に、グループ化したデータをデータベースに保存します。

export async function importPesticidesCSV(formData) {
  // ... 認証チェック、CSVパース、グループ化 ...

  for (const pesticide of groupedPesticides) {
    // 農薬マスタに1件登録
    const { data: pesticideData } = await supabase
      .from("cult_pesticides")
      .insert({
        owner_id: user.id,
        name: pesticide.name,
        type: pesticide.type,
      })
      .select()
      .single();

    // 適用詳細を複数件登録
    const applicationsToInsert = pesticide.applications.map((app) => ({
      owner_id: user.id,
      pesticide_id: pesticideData.id, // ← 上で取得したID
      target_pest: app.targetPest,
      dilution_ratio_min: app.dilutionRatioMin,
      dilution_ratio_max: app.dilutionRatioMax,
      // ... 他の情報
    }));

    await supabase
      .from("cult_pesticide_applications")
      .insert(applicationsToInsert);
  }

  return { success: true, message: `${successCount}件の農薬を登録しました` };
}

処理の流れ:

  1. 農薬マスタ(cult_pesticides)に「ウララDF」を1件登録
  2. 登録したIDを取得
  3. そのIDを使って、適用詳細(cult_pesticide_applications)に「アブラムシ類用」と「アザミウマ用」を2件登録

これを繰り返して、すべての農薬を登録します。

結果

一度CSVをアップロードするだけで、農薬マスタが完成しました。

手動で何十件も登録する手間が省けたのは、かなり大きいです。

ステップ2: 農薬一覧で確認・編集

CSVインポート後、農薬一覧画面で確認できます。

一覧画面の機能

実装した機能:

  • 用途別フィルタ(全て、殺菌剤、殺虫剤、除草剤など)
  • インライン編集(農薬名、用途、登録番号、メモ、適用詳細)
  • 適用詳細の表示(アコーディオン)
  • 病害虫、使用方法、希釈倍率、使用時期、使用液量、使用回数
  • 削除機能(個別 + 一括)

最初は農薬名と用途だけ編集できる仕様でしたが、CSVインポート後に細かい修正をしたくなったので、適用詳細(病害虫、使用方法、希釈倍率など)も編集可能にしました。

ステップ3: お気に入り機能の実装と改善

農薬が何百件もあると、毎回全部から選ぶのは大変です。

そこで、「お気に入り機能」を実装しました。

お気に入りの基本的な使い方

よく使う農薬だけを「☆ 登録」ボタンでお気に入りに追加すると、「★ お気に入り」フィルタで絞り込めます。

防除作業記録時は、お気に入り農薬だけが選択肢に表示される仕組みです。

実際の使い方:

アスパラガス栽培で使う農薬の一部:

  • ウララDF(殺虫剤)
  • モベントフロアブル(殺虫剤)

例えばこの2種類をお気に入り登録しておけば、防除作業時はこの2つから選ぶだけで済みます

遭遇したバグ: お気に入り解除が保存されない

最初、お気に入り解除ボタンを押しても、ページを更新すると元に戻ってしまうバグがありました。

原因は、お気に入りの更新が成功したかどうかをユーザーに伝えていなかったことでした。

解決策:

成功メッセージを追加して、視覚的なフィードバックを改善しました。

const handleToggleFavorite = async (pesticideId, currentFavorite) => {
  const result = await toggleFavorite(pesticideId, !currentFavorite);

  if (result.success) {
    // 成功メッセージを追加
    setMessage({
      type: "success",
      text: !currentFavorite
        ? "お気に入りに登録しました"
        : "お気に入りを解除しました",
    });
    setTimeout(() => setMessage(null), 2000);
  } else {
    setMessage({
      type: "error",
      text: result.error || "お気に入りの更新に失敗しました",
    });
  }
};

メッセージを2秒間表示することで、「ちゃんと登録/解除された」という安心感を与えられるようになりました。

一括操作の実装と404エラーの解決

チェックボックスで複数選択して、一括でお気に入り登録/解除できる機能も追加しました。

最初の実装:

最初は、選択された農薬を1件ずつループで処理していました。

// ❌ これだと遅い & エラーが出る
for (const id of selectedIds) {
  await toggleFavorite(id, true);
}

しかし、40件以上選択すると404エラーが発生しました。

解決策:

1回のSQL文で一括更新するtoggleFavoriteBulkを実装しました。

// ✅ 1回のSQLで全件更新
export async function toggleFavoriteBulk(pesticideIds, isFavorite) {
  const supabase = await createClient();

  const { data: { user } } = await supabase.auth.getUser();
  if (!user) redirect("/login");

  try {
    const { error } = await supabase
      .from("cult_pesticides")
      .update({
        is_favorite: isFavorite,
        updated_at: new Date().toISOString(),
      })
      .in("id", pesticideIds)
      .eq("owner_id", user.id);

    if (error) {
      return { success: false, error: "一括更新に失敗しました" };
    }

    revalidatePath("/pesticides");
    return { success: true, count: pesticideIds.length };
  } catch (error) {
    return { success: false, error: "予期しないエラーが発生しました" };
  }
}

.in("id", pesticideIds)を使うことで、複数のIDを一度に更新できます。

これで、40件以上でもエラーなく処理できるようになりました。

誤操作防止: 一括解除の確認ダイアログ

一括でお気に入り解除するのは、誤操作のリスクが高いです。

そこで、確認ダイアログを追加しました。

const handleBulkRemoveFavorite = async () => {
  if (selectedIds.size === 0) {
    setMessage({ type: "error", text: "農薬を選択してください" });
    return;
  }

  // 確認ダイアログを追加
  if (!confirm(`選択した${selectedIds.size}件の農薬をお気に入りから解除しますか?`)) {
    return;
  }

  const result = await toggleFavoriteBulk(Array.from(selectedIds), false);
  // ...
};

これで、うっかり解除してしまうミスを防げます。

フィルタ切り替え時のチェックリセット

フィルタを切り替えると、画面に表示される農薬が変わります。

しかし、チェック状態がそのまま残っていると、「今選択されているのはどれ?」と混乱します。

そこで、フィルタ切り替え時に自動でチェック状態をリセットする処理を追加しました。

const handleFilterChange = (newFilter) => {
  setFilterType(newFilter);
  setSelectedIds(new Set()); // チェック状態をリセット
};

これで、フィルタを切り替えるたびにクリーンな状態から選択できるようになりました。

ステップ4: 防除作業の記録

ハウス管理画面で、実際に防除作業を記録します。

防除作業の流れ

  1. 畝を選択: 畝1, 畝2, 畝3
  2. 防除アイコンをタップ
  3. お気に入り農薬から選択: ウララDF を選択
  4. 対象病害虫を選択(ラジオボタン):
  • [ ] アブラムシ類 – 2000倍
  • [✓] ネギアザミウマ – 2000倍
  1. 希釈倍率が自動表示:
  • 推奨倍率: 2000倍
  • 実際の倍率を入力: [2000] 倍(手動で変更も可能)
  1. 原液量を入力:
  • 原液: [25] ml
  • → 散布量: 50.0 L(自動計算)
  1. 保存

データベースに保存される内容

作業記録として保存されるデータ:

  • 日付、ハウス、畝
  • 使用した農薬(マスタへの参照)
  • 対象病害虫(詳細への参照)
  • 実際の希釈倍率
  • 原液量、散布量

これで、「いつ、どのハウスの、どの畝に、何の農薬を、どれくらい使ったか」が記録できます。

実装してみて気づいたこと

お気に入り機能の重要性

最初は「フィルタだけで十分」と思っていました。

でも、農薬が何百件もあると、毎回探すのが大変です。

お気に入り機能を作ったら、作業記録が楽になるのではないかと思ってます。

バグ修正から学んだこと:

お気に入り解除が保存されないバグは、「成功メッセージがない」ことが原因でした。

ユーザーに「ちゃんと処理された」という安心感を与えることの重要性を実感しました。

一括操作の実装で学んだこと

最初は1件ずつループで処理していましたが、40件以上で404エラーが発生しました。

1回のSQL文で一括更新する方式に変更したことで:

  • 速度が向上: 40件の処理が一瞬で終わる
  • エラーが解消: 何件選択してもエラーなし
  • コードがシンプル: ループが不要になった

データベース操作は、できるだけ一括で処理する方が良いということを学びました。

希釈倍率の自動表示

対象病害虫を選ぶと、推奨倍率が自動で表示される仕様にしました。

これがあると、「あれ、何倍だっけ?」と調べる手間が省けます。

また、間違った倍率で使用するミスも防げます。

データの流れが見えると安心

「CSVインポート」→「お気に入り」→「作業記録」という流れが繋がったことで、データがどこに保存されて、どう使われるかが明確になりました。

データの流れが見えると、安心して使えます。

今後の予定

次にやること

  • 原液量 ⇄ 散布量の相互変換(散布量から原液量を逆算)
  • ハウス管理の削除機能
  • ハウス新規登録のUI改善
  • モバイルレスポンシブ対応の調整

後回しにしたこと

  • 検索機能の拡張(キーワード検索は実装済み)
  • 不要ページの削除
  • カレンダービュー
  • 天気自動取得

まとめ

今回は、農薬マスタのCSVインポートから、実際の防除作業記録まで、データの流れを一通り実装しました。

特に、お気に入り機能を作ったことで、作業記録時の農薬選択が格段に楽になりました。

また、一括操作の404エラーを解決する過程で、「データベース操作は一括で処理する方が良い」という重要な学びを得ました。

対象病害虫を選ぶと希釈倍率が自動表示される仕様にしたことで、「あれ、何倍だっけ?」と調べる手間が省けて、間違った倍率で使用するミスも防げるようになりました。

目次