From 8b5e0fc66ee4b2248098e33ec7557209cb313898 Mon Sep 17 00:00:00 2001 From: Akira Date: Thu, 19 Feb 2026 12:21:17 +0900 Subject: [PATCH] =?UTF-8?q?A-6=20=E5=AE=8C=E4=BA=86=E3=80=82=20=E6=9C=AC?= =?UTF-8?q?=E3=82=BB=E3=83=83=E3=82=B7=E3=83=A7=E3=83=B3=E3=81=AE=E9=80=B2?= =?UTF-8?q?=E6=8D=97=E3=81=BE=E3=81=A8=E3=82=81:?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit タスク 内容 状態 A-3 前年度コピーボタン ✅ 完了 A-4 品種のインライン追加・削除 ✅ 完了 A-5 PDFプレビュー機能 ✅ 完了 A-6 エクスポート機能 ✅ 完了 残りタスク: A-2: チェックボックス・一括操作 A-1: ダッシュボード画面 A-7: 検索・フィルタ 確認ポイント: 作付け計画 (/allocation): 年度セレクタの横に「前年度コピー」「品種管理」ボタン、品種セレクトに「+ 新しい品種を追加...」 帳票出力 (/reports): 各帳票にプレビュー/ダウンロードの2ボタン データ取込 (/import): ページ下部に「データエクスポート」(ZIPダウンロード) --- CLAUDE.md | 17 +- backend/apps/fields/urls.py | 1 + backend/apps/fields/views.py | 82 +++++- .../0003_variety_on_delete_set_null.py | 19 ++ backend/apps/plans/models.py | 2 +- .../06_ドキュメントvs実装_差異レポート.md | 71 ++--- frontend/src/app/allocation/page.tsx | 248 ++++++++++++++++-- frontend/src/app/import/page.tsx | 51 +++- frontend/src/app/reports/page.tsx | 134 ++++++---- 9 files changed, 497 insertions(+), 128 deletions(-) create mode 100644 backend/apps/plans/migrations/0003_variety_on_delete_set_null.py diff --git a/CLAUDE.md b/CLAUDE.md index 63c4a9c..11b4b7d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -202,6 +202,10 @@ Variety (品種マスタ) - 圃場一覧・詳細・新規作成 - データ取込画面 - 申請書ダウンロード画面 +6. **対応付け可視化・紐づけ管理** (E-2): + - 圃場一覧「対応表」モード(共済漢字地名・中山間所在地の一覧表示、直接紐づけ追加・解除) + - 圃場詳細画面の共済/中山間リンク管理(+追加、×解除、面積参考表示) + - 共通 LinkModal コンポーネント ### 🚧 既知の課題・技術的負債 @@ -209,18 +213,11 @@ Variety (品種マスタ) 2. **エラーハンドリング**: フロントエンドでの統一的なエラー表示が未実装 3. **テスト**: 自動テストが未実装(Phase 2で追加予定) 4. **パフォーマンス**: N+1問題が一部存在(現状は問題ないが、データ増加時に対応必要) -5. **対応付け可視化**: 圃場と共済/中山間マスタのM:N紐づけを管理する画面がない(E-2で対応予定) - ### 🔜 次の実装タスク(優先順) -1. **E-2**: 対応付け可視化・紐づけ管理(圃場詳細画面の拡張、面積整合性チェック) -2. **A-3**: 前年度コピーボタン(Frontend) -3. **A-4**: 品種のインライン追加・削除 -4. **A-5**: PDFプレビュー機能 -5. **A-6**: エクスポート機能(サーバー移行時のデータ移動用) -6. **A-2**: チェックボックス・一括操作 -7. **A-1**: ダッシュボード画面 -8. **A-7**: 検索・フィルタ +1. **A-2**: チェックボックス・一括操作 +2. **A-1**: ダッシュボード画面 +3. **A-7**: 検索・フィルタ 詳細は `document/06_ドキュメントvs実装_差異レポート.md` を参照 diff --git a/backend/apps/fields/urls.py b/backend/apps/fields/urls.py index 4a29aaa..a98f85b 100644 --- a/backend/apps/fields/urls.py +++ b/backend/apps/fields/urls.py @@ -9,6 +9,7 @@ urlpatterns = [ path('import/kyosai/', views.import_kyosai_master, name='import_kyosai'), path('import/yoshida/', views.import_yoshida_fields, name='import_yoshida'), path('import/chusankan/', views.import_chusankan_master, name='import_chusankan'), + path('export/zip/', views.export_all_zip, name='export_all_zip'), path('/kyosai-links/', views.add_kyosai_links, name='add_kyosai_links'), path('/kyosai-links//', views.remove_kyosai_link, name='remove_kyosai_link'), path('/chusankan-links/', views.add_chusankan_links, name='add_chusankan_links'), diff --git a/backend/apps/fields/views.py b/backend/apps/fields/views.py index c0f5475..27464b7 100644 --- a/backend/apps/fields/views.py +++ b/backend/apps/fields/views.py @@ -1,6 +1,10 @@ +import csv +import io +import json +import zipfile import pandas as pd from django.db import models as django_models -from django.http import JsonResponse +from django.http import JsonResponse, HttpResponse from django.shortcuts import get_object_or_404 from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_http_methods @@ -298,3 +302,79 @@ def import_chusankan_master(request): except Exception as e: return JsonResponse({'error': str(e)}, status=500) + + +@api_view(['GET']) +@perm_classes([permissions.IsAuthenticated]) +def export_all_zip(request): + """全データをCSV形式でZIPアーカイブとしてエクスポート""" + from apps.plans.models import Plan, Crop, Variety + + buf = io.BytesIO() + with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf: + # 1. Fields CSV + fields_csv = io.StringIO() + w = csv.writer(fields_csv) + w.writerow(['id', 'name', 'address', 'area_tan', 'area_m2', 'owner_name', 'group_name', 'display_order']) + for f in Field.objects.all().order_by('id'): + w.writerow([f.id, f.name, f.address, f.area_tan, f.area_m2, f.owner_name, f.group_name or '', f.display_order or 0]) + zf.writestr('fields.csv', fields_csv.getvalue()) + + # 2. Kyosai CSV + kyosai_csv = io.StringIO() + w = csv.writer(kyosai_csv) + w.writerow(['id', 'k_num', 's_num', 'address', 'kanji_name', 'area']) + for k in OfficialKyosaiField.objects.all().order_by('id'): + w.writerow([k.id, k.k_num, k.s_num, k.address, k.kanji_name, k.area]) + zf.writestr('kyosai_fields.csv', kyosai_csv.getvalue()) + + # 3. Chusankan CSV + chusankan_csv = io.StringIO() + w = csv.writer(chusankan_csv) + w.writerow(['id', 'c_id', 'chusankan_flag', 'oaza', 'aza', 'chiban', 'branch_num', + 'land_type', 'area', 'planting_area', 'original_crop', 'manager', 'owner', + 'slope', 'base_amount', 'steep_slope_addition', 'smart_agri_addition', 'payment_amount']) + for c in OfficialChusankanField.objects.all().order_by('id'): + w.writerow([c.id, c.c_id, c.chusankan_flag or '', c.oaza, c.aza, c.chiban, + c.branch_num or '', c.land_type or '', c.area, c.planting_area or '', + c.original_crop or '', c.manager or '', c.owner or '', c.slope or '', + c.base_amount or '', c.steep_slope_addition or '', c.smart_agri_addition or '', + c.payment_amount or '']) + zf.writestr('chusankan_fields.csv', chusankan_csv.getvalue()) + + # 4. Plans CSV + plans_csv = io.StringIO() + w = csv.writer(plans_csv) + w.writerow(['id', 'field_id', 'field_name', 'year', 'crop_id', 'crop_name', 'variety_id', 'variety_name', 'notes']) + for p in Plan.objects.select_related('field', 'crop', 'variety').all().order_by('year', 'field_id'): + w.writerow([p.id, p.field_id, p.field.name, p.year, p.crop_id, p.crop.name, + p.variety_id or '', p.variety.name if p.variety else '', p.notes or '']) + zf.writestr('plans.csv', plans_csv.getvalue()) + + # 5. Crops & Varieties CSV + crops_csv = io.StringIO() + w = csv.writer(crops_csv) + w.writerow(['crop_id', 'crop_name', 'variety_id', 'variety_name']) + for crop in Crop.objects.prefetch_related('varieties').all().order_by('id'): + if crop.varieties.count() == 0: + w.writerow([crop.id, crop.name, '', '']) + else: + for v in crop.varieties.all().order_by('id'): + w.writerow([crop.id, crop.name, v.id, v.name]) + zf.writestr('crops_varieties.csv', crops_csv.getvalue()) + + # 6. M:N links (field_kyosai, field_chusankan) + links_csv = io.StringIO() + w = csv.writer(links_csv) + w.writerow(['field_id', 'field_name', 'link_type', 'linked_id']) + for f in Field.objects.prefetch_related('kyosai_fields', 'chusankan_fields').all().order_by('id'): + for k in f.kyosai_fields.all(): + w.writerow([f.id, f.name, 'kyosai', k.id]) + for c in f.chusankan_fields.all(): + w.writerow([f.id, f.name, 'chusankan', c.id]) + zf.writestr('field_links.csv', links_csv.getvalue()) + + buf.seek(0) + response = HttpResponse(buf.read(), content_type='application/zip') + response['Content-Disposition'] = 'attachment; filename="keinasystem_backup.zip"' + return response diff --git a/backend/apps/plans/migrations/0003_variety_on_delete_set_null.py b/backend/apps/plans/migrations/0003_variety_on_delete_set_null.py new file mode 100644 index 0000000..2707238 --- /dev/null +++ b/backend/apps/plans/migrations/0003_variety_on_delete_set_null.py @@ -0,0 +1,19 @@ +# Generated by Django 5.0 on 2026-02-19 03:13 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('plans', '0002_alter_plan_variety'), + ] + + operations = [ + migrations.AlterField( + model_name='plan', + name='variety', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='plans', to='plans.variety', verbose_name='品種'), + ), + ] diff --git a/backend/apps/plans/models.py b/backend/apps/plans/models.py index 461857c..2d58cc8 100644 --- a/backend/apps/plans/models.py +++ b/backend/apps/plans/models.py @@ -30,7 +30,7 @@ class Plan(models.Model): field = models.ForeignKey(Field, on_delete=models.CASCADE, related_name='plans', verbose_name="圃場") year = models.IntegerField(verbose_name="作付年度") crop = models.ForeignKey(Crop, on_delete=models.CASCADE, related_name='plans', verbose_name="作物") - variety = models.ForeignKey(Variety, on_delete=models.CASCADE, related_name='plans', verbose_name="品種", blank=True, null=True) + variety = models.ForeignKey(Variety, on_delete=models.SET_NULL, related_name='plans', verbose_name="品種", blank=True, null=True) notes = models.TextField(blank=True, null=True, verbose_name="備考") class Meta: diff --git a/document/06_ドキュメントvs実装_差異レポート.md b/document/06_ドキュメントvs実装_差異レポート.md index 58c53aa..7504f01 100644 --- a/document/06_ドキュメントvs実装_差異レポート.md +++ b/document/06_ドキュメントvs実装_差異レポート.md @@ -30,47 +30,34 @@ --- -### A-3: 前年度コピー機能(フロントエンド) +### ~~A-3: 前年度コピー機能(フロントエンド)~~ ✅ 対応済み -- **ドキュメント**: ユーザーストーリー P1-5、画面設計書 画面3 - [前年度をコピー]ボタン -- **実装**: Backend API (`POST /api/plans/copy_from_previous_year/`) は存在するが、Frontend にボタンがない -- **影響**: 毎年手動で39筆を設定する必要がある -- **状態**: 🔜 未着手 - -**対応方針**: 必要な項目です。 +- **対応内容**: 作付け計画画面(/allocation)の年度セレクタ横に[前年度コピー]ボタンを追加。確認ダイアログ付き、既存プランはスキップ(ignore_conflicts) +- **対応日**: 2026-02-18 --- -### A-4: 品種のインライン追加 +### ~~A-4: 品種のインライン追加・削除~~ ✅ 対応済み -- **ドキュメント**: 画面設計書 画面4 - [+ 新しい品種を追加]ボタン、その場で入力して即座にマスタ登録 -- **実装**: 既存品種からの選択のみ。新品種の追加はDjango管理画面からのみ可能 -- **影響**: 運用中に新品種が出てきた場合、管理画面を開く必要がある -- **状態**: 🔜 未着手 - -**対応方針**: 追加出来る事は必要です。削除も出来ないと間違って追加した時に不便です +- **対応内容**: + - 品種セレクトに「+ 新しい品種を追加...」オプション追加。選択するとインライン入力に切り替わり、Enter/追加ボタンでAPI経由で即登録&自動選択 + - ヘッダーに「品種管理」ボタン追加。モーダルで作物別の品種一覧表示、追加・削除が可能 + - Plan.variety の on_delete を CASCADE → SET_NULL に変更(品種削除時に計画が消えない安全策、マイグレーション0003) +- **対応日**: 2026-02-19 --- -### A-5: PDFプレビュー機能 +### ~~A-5: PDFプレビュー機能~~ ✅ 対応済み -- **ドキュメント**: 画面設計書 画面6 - [プレビュー]ボタンで新タブにPDF表示 -- **実装**: ダウンロードボタンのみ(プレビューなし) -- **影響**: ダウンロード前に内容確認ができない -- **状態**: 🔜 未着手 - -**対応方針**: プレビューしてから保存、もしくは、印刷出来るようにしたいです。 +- **対応内容**: 帳票出力画面(/reports)をカード形式にリニューアル。各帳票にプレビュー(新タブでPDF表示)とダウンロードのボタンを配置。プレビューからブラウザの印刷機能で直接印刷可能 +- **対応日**: 2026-02-19 --- -### A-6: エクスポート機能(CSV/ZIP) +### ~~A-6: エクスポート機能(CSV/ZIP)~~ ✅ 対応済み -- **ドキュメント**: 画面設計書 画面7 - 全圃場データCSV、作付け計画CSV、全データZIPバックアップ -- **実装**: 未実装 -- **影響**: バックアップ手段がない(DBダンプのみ) -- **状態**: 🔜 未着手 - -**対応方針**: 必要です。近い将来サーバーに移行するので、その時に、このローカル環境で設定したデータを移動できるようにしたいです。 +- **対応内容**: データ取込画面(/import)下部に「データエクスポート」セクション追加。全データ(圃場・共済・中山間・作付け計画・品種・M:N紐づけ)を6つのCSVファイルとしてZIPアーカイブでダウンロード。バックエンドAPI `GET /api/fields/export/zip/` +- **対応日**: 2026-02-19 --- @@ -221,18 +208,16 @@ --- -### E-2: 対応付け可視化・紐づけ管理機能 +### ~~E-2: 対応付け可視化・紐づけ管理機能~~ ✅ 対応済み -- **背景**: 3つのODSデータファイル(吉田農地台帳 → Field、水稲共済細目用 → OfficialKyosaiField、中山間 → OfficialChusankanField)間のM:N対応関係を確認・編集する手段がない -- **状態**: 🚧 一部実装済み +**対応日**: 2026-02-18 -**実装済み:** -- ✅ バックエンドAPI 6本(共済/中山間マスタ一覧、紐づけ追加・解除) -- ✅ 圃場詳細画面(/fields/[id]): +追加ボタン、×解除ボタン、検索付きモーダル、面積参考表示 -- ✅ 圃場一覧 通常モード: 「共済」「中山間」件数列 - -**未実装:** -- 🔜 圃場一覧「対応表」モード: 漢字地名・所在地を一覧表示し、直接紐づけ追加・解除できる表示モード(仕様は画面設計書 画面4 に記載済み) +**対応内容:** +- バックエンドAPI 6本(共済/中山間マスタ一覧、紐づけ追加・解除) +- 圃場詳細画面(/fields/[id]): +追加ボタン、×解除ボタン、検索付きモーダル、面積参考表示 +- 圃場一覧 通常モード: 「共済」「中山間」件数列 +- 圃場一覧「対応表」モード: [通常]/[対応表]トグルで切替、圃場名・住所・面積・共済漢字地名・中山間所在地を一覧表示、直接紐づけ追加・解除可能 +- 共通コンポーネント: LinkModal(検索付き複数選択モーダル)を抽出 --- @@ -242,14 +227,14 @@ |---------|------|------| | A-1 | ダッシュボード画面 | 🔜 未着手 | | A-2 | チェックボックス一括操作 | 🔜 未着手 | -| A-3 | 前年度コピーボタン | 🔜 未着手 | -| A-4 | 品種インライン追加・削除 | 🔜 未着手 | -| A-5 | PDFプレビュー | 🔜 未着手 | -| A-6 | エクスポート機能 | 🔜 未着手 | +| A-3 | 前年度コピーボタン | ✅ 完了 | +| A-4 | 品種インライン追加・削除 | ✅ 完了 | +| A-5 | PDFプレビュー | ✅ 完了 | +| A-6 | エクスポート機能 | ✅ 完了 | | A-7 | 検索・フィルタ | 🔜 未着手 | | A-8 | 圃場詳細 共済/中山間表示 | ✅ 完了 | | B-1〜B-5 | ドキュメント追記 | ✅ 完了 | | C-1〜C-8 | ドキュメント/実装の食い違い修正 | ✅ 全件完了 | | D-1〜D-4 | 不具合修正 | ✅ 全件完了 | | E-1 | PDF帳票再設計 | ✅ 完了 | -| E-2 | 対応付け可視化・紐づけ管理 | 🚧 一部実装済み(対応表モード未実装) | +| E-2 | 対応付け可視化・紐づけ管理 | ✅ 完了 | diff --git a/frontend/src/app/allocation/page.tsx b/frontend/src/app/allocation/page.tsx index 7039b21..7fbbaec 100644 --- a/frontend/src/app/allocation/page.tsx +++ b/frontend/src/app/allocation/page.tsx @@ -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 } from 'lucide-react'; +import { Menu, X, BarChart3, ChevronRight, ChevronDown, ArrowUp, ArrowDown, Copy, Settings, Trash2 } from 'lucide-react'; interface SummaryItem { cropId: number; @@ -30,6 +30,11 @@ export default function AllocationPage() { const [showMobileSummary, setShowMobileSummary] = useState(false); const [expandedCrops, setExpandedCrops] = useState>(new Set()); const [sortType, setSortType] = useState('custom'); + const [copying, setCopying] = useState(false); + const [addingVariety, setAddingVariety] = useState<{ fieldId: number; cropId: number } | null>(null); + const [newVarietyName, setNewVarietyName] = useState(''); + const [showVarietyManager, setShowVarietyManager] = useState(false); + const [managerCropId, setManagerCropId] = useState(null); useEffect(() => { fetchData(); @@ -279,6 +284,63 @@ export default function AllocationPage() { } }; + const handleAddVariety = async (fieldId: number, cropId: number) => { + const name = newVarietyName.trim(); + if (!name) return; + + try { + const res = await api.post('/plans/varieties/', { crop: cropId, name }); + setNewVarietyName(''); + setAddingVariety(null); + await fetchData(true); + // Auto-select the new variety + const plan = getPlanForField(fieldId); + if (plan) { + await api.patch(`/plans/${plan.id}/`, { variety: res.data.id }); + await fetchData(true); + } + } catch (error: any) { + if (error.response?.status === 400) { + alert('この品種名は既に登録されています'); + } else { + console.error('Failed to add variety:', error); + alert('品種の追加に失敗しました'); + } + } + }; + + const handleDeleteVariety = async (varietyId: number, varietyName: string) => { + if (!confirm(`品種「${varietyName}」を削除しますか?\nこの品種が設定されている作付け計画がある場合、削除できません。`)) return; + + try { + await api.delete(`/plans/varieties/${varietyId}/`); + await fetchData(true); + } catch (error: any) { + console.error('Failed to delete variety:', error); + alert('品種の削除に失敗しました。使用中の品種は削除できません。'); + } + }; + + const handleCopyFromPreviousYear = async () => { + const fromYear = year - 1; + if (!confirm(`${fromYear}年度の作付け計画を${year}年度にコピーします。\n既に設定済みの圃場はスキップされます。\n実行しますか?`)) return; + + setCopying(true); + try { + const res = await api.post('/plans/copy_from_previous_year/', { + from_year: fromYear, + to_year: year, + }); + alert(res.data.message || 'コピーが完了しました'); + await fetchData(); + } catch (error: any) { + console.error('Failed to copy:', error); + alert(error.response?.data?.error || 'コピーに失敗しました'); + } finally { + setCopying(false); + } + }; + const getVarietiesForCrop = (cropId: number) => { const crop = crops.find((c) => c.id === cropId); return crop?.varieties || []; @@ -436,6 +498,25 @@ export default function AllocationPage() { + + + + @@ -557,23 +638,56 @@ export default function AllocationPage() { - + {addingVariety?.fieldId === field.id ? ( +
+ setNewVarietyName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') handleAddVariety(field.id, addingVariety.cropId); + if (e.key === 'Escape') { setAddingVariety(null); setNewVarietyName(''); } + }} + placeholder="品種名を入力" + className="px-2 py-1.5 border border-green-400 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-green-500 w-32" + autoFocus + /> + + +
+ ) : ( + + )} )} + {/* 品種管理モーダル */} + {showVarietyManager && ( +
+
+
+

品種管理

+ +
+ +
+ + +
+ +
+ {managerCropId && getVarietiesForCrop(managerCropId).length > 0 ? ( +
    + {getVarietiesForCrop(managerCropId).map((v) => ( +
  • + {v.name} + +
  • + ))} +
+ ) : ( +

品種が登録されていません

+ )} +
+ +
+ { + if (!managerCropId) return; + try { + await api.post('/plans/varieties/', { crop: managerCropId, name }); + await fetchData(true); + } catch (error: any) { + if (error.response?.status === 400) { + alert('この品種名は既に登録されています'); + } else { + alert('品種の追加に失敗しました'); + } + } + }} /> +
+
+
+ )} + + ); +} + +function VarietyAddForm({ cropId, onAdd }: { cropId: number | null; onAdd: (name: string) => Promise }) { + const [name, setName] = useState(''); + const [adding, setAdding] = useState(false); + + const handleSubmit = async () => { + const trimmed = name.trim(); + if (!trimmed) return; + setAdding(true); + await onAdd(trimmed); + setName(''); + setAdding(false); + }; + + return ( +
+ setName(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') handleSubmit(); }} + placeholder="新しい品種名" + disabled={!cropId || adding} + className="flex-1 px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-green-500 disabled:opacity-50" + /> +
); } diff --git a/frontend/src/app/import/page.tsx b/frontend/src/app/import/page.tsx index 9a1dc36..09f0b9a 100644 --- a/frontend/src/app/import/page.tsx +++ b/frontend/src/app/import/page.tsx @@ -3,7 +3,7 @@ import { useState, useRef } from 'react'; import { api } from '@/lib/api'; import Navbar from '@/components/Navbar'; -import { Upload, Loader2, CheckCircle, XCircle } from 'lucide-react'; +import { Upload, Download, Loader2, CheckCircle, XCircle } from 'lucide-react'; interface ImportResult { success: boolean; @@ -20,10 +20,32 @@ export default function ImportPage() { const [kyosaiResult, setKyosaiResult] = useState(null); const [yoshidaResult, setYoshidaResult] = useState(null); const [chusankanResult, setChusankanResult] = useState(null); + const [exporting, setExporting] = useState(false); const kyosaiInputRef = useRef(null); const yoshidaInputRef = useRef(null); const chusankanInputRef = useRef(null); + const handleExportZip = async () => { + setExporting(true); + try { + const response = await api.get('/fields/export/zip/', { responseType: 'blob' }); + const blob = new Blob([response.data], { type: 'application/zip' }); + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = 'keinasystem_backup.zip'; + document.body.appendChild(link); + link.click(); + link.remove(); + window.URL.revokeObjectURL(url); + } catch (error) { + console.error('Export failed:', error); + alert('エクスポートに失敗しました'); + } finally { + setExporting(false); + } + }; + const handleKyosaiUpload = async () => { if (!kyosaiFile) { alert('ファイルを選択してください'); @@ -399,6 +421,33 @@ export default function ImportPage() { )} + + {/* エクスポート */} +
+

+ データエクスポート +

+

+ 全データ(圃場・共済・中山間・作付け計画・品種・紐づけ情報)をCSV形式のZIPファイルとしてダウンロードします。サーバー移行時のバックアップとして使用できます。 +

+ +
diff --git a/frontend/src/app/reports/page.tsx b/frontend/src/app/reports/page.tsx index 68fa9ba..925739d 100644 --- a/frontend/src/app/reports/page.tsx +++ b/frontend/src/app/reports/page.tsx @@ -3,7 +3,7 @@ import { useState } from 'react'; import { api } from '@/lib/api'; import Navbar from '@/components/Navbar'; -import { FileDown, Loader2 } from 'lucide-react'; +import { FileDown, Eye, Loader2 } from 'lucide-react'; const downloadPdf = async (url: string, filename: string) => { const response = await api.get(url, { responseType: 'blob' }); @@ -18,31 +18,35 @@ const downloadPdf = async (url: string, filename: string) => { window.URL.revokeObjectURL(downloadUrl); }; +const previewPdf = async (url: string) => { + const response = await api.get(url, { responseType: 'blob' }); + const blob = new Blob([response.data], { type: 'application/pdf' }); + const previewUrl = window.URL.createObjectURL(blob); + window.open(previewUrl, '_blank'); +}; + export default function ReportsPage() { const [year, setYear] = useState(2025); - const [downloading, setDownloading] = useState(null); + const [busy, setBusy] = useState(null); - const handleDownloadKyosai = async () => { - setDownloading('kyosai'); + const handleAction = async (action: 'download' | 'preview', type: 'kyosai' | 'chusankan') => { + const key = `${action}-${type}`; + setBusy(key); try { - await downloadPdf(`/reports/kyosai/${year}/`, `水稲共済細目書_${year}.pdf`); + const url = `/reports/${type}/${year}/`; + if (action === 'download') { + const filename = type === 'kyosai' + ? `水稲共済細目書_${year}.pdf` + : `中山間交付金申請書_${year}.pdf`; + await downloadPdf(url, filename); + } else { + await previewPdf(url); + } } catch (error) { - console.error('Download failed:', error); - alert('ダウンロードに失敗しました'); + console.error(`${action} failed:`, error); + alert(`${action === 'download' ? 'ダウンロード' : 'プレビュー'}に失敗しました`); } finally { - setDownloading(null); - } - }; - - const handleDownloadChusankan = async () => { - setDownloading('chusankan'); - try { - await downloadPdf(`/reports/chusankan/${year}/`, `中山間交付金申请书_${year}.pdf`); - } catch (error) { - console.error('Download failed:', error); - alert('ダウンロードに失敗しました'); - } finally { - setDownloading(null); + setBusy(null); } }; @@ -70,41 +74,63 @@ export default function ReportsPage() {
- + {/* 水稲共済細目書 */} +
+

水稲共済細目書

+
+ + +
+
- + {/* 中山間交付金申請書 */} +
+

中山間交付金申請書

+
+ + +
+