A-6 完了。 本セッションの進捗まとめ:
タスク 内容 状態 A-3 前年度コピーボタン ✅ 完了 A-4 品種のインライン追加・削除 ✅ 完了 A-5 PDFプレビュー機能 ✅ 完了 A-6 エクスポート機能 ✅ 完了 残りタスク: A-2: チェックボックス・一括操作 A-1: ダッシュボード画面 A-7: 検索・フィルタ 確認ポイント: 作付け計画 (/allocation): 年度セレクタの横に「前年度コピー」「品種管理」ボタン、品種セレクトに「+ 新しい品種を追加...」 帳票出力 (/reports): 各帳票にプレビュー/ダウンロードの2ボタン データ取込 (/import): ページ下部に「データエクスポート」(ZIPダウンロード)
This commit is contained in:
17
CLAUDE.md
17
CLAUDE.md
@@ -202,6 +202,10 @@ Variety (品種マスタ)
|
|||||||
- 圃場一覧・詳細・新規作成
|
- 圃場一覧・詳細・新規作成
|
||||||
- データ取込画面
|
- データ取込画面
|
||||||
- 申請書ダウンロード画面
|
- 申請書ダウンロード画面
|
||||||
|
6. **対応付け可視化・紐づけ管理** (E-2):
|
||||||
|
- 圃場一覧「対応表」モード(共済漢字地名・中山間所在地の一覧表示、直接紐づけ追加・解除)
|
||||||
|
- 圃場詳細画面の共済/中山間リンク管理(+追加、×解除、面積参考表示)
|
||||||
|
- 共通 LinkModal コンポーネント
|
||||||
|
|
||||||
### 🚧 既知の課題・技術的負債
|
### 🚧 既知の課題・技術的負債
|
||||||
|
|
||||||
@@ -209,18 +213,11 @@ Variety (品種マスタ)
|
|||||||
2. **エラーハンドリング**: フロントエンドでの統一的なエラー表示が未実装
|
2. **エラーハンドリング**: フロントエンドでの統一的なエラー表示が未実装
|
||||||
3. **テスト**: 自動テストが未実装(Phase 2で追加予定)
|
3. **テスト**: 自動テストが未実装(Phase 2で追加予定)
|
||||||
4. **パフォーマンス**: N+1問題が一部存在(現状は問題ないが、データ増加時に対応必要)
|
4. **パフォーマンス**: N+1問題が一部存在(現状は問題ないが、データ増加時に対応必要)
|
||||||
5. **対応付け可視化**: 圃場と共済/中山間マスタのM:N紐づけを管理する画面がない(E-2で対応予定)
|
|
||||||
|
|
||||||
### 🔜 次の実装タスク(優先順)
|
### 🔜 次の実装タスク(優先順)
|
||||||
|
|
||||||
1. **E-2**: 対応付け可視化・紐づけ管理(圃場詳細画面の拡張、面積整合性チェック)
|
1. **A-2**: チェックボックス・一括操作
|
||||||
2. **A-3**: 前年度コピーボタン(Frontend)
|
2. **A-1**: ダッシュボード画面
|
||||||
3. **A-4**: 品種のインライン追加・削除
|
3. **A-7**: 検索・フィルタ
|
||||||
4. **A-5**: PDFプレビュー機能
|
|
||||||
5. **A-6**: エクスポート機能(サーバー移行時のデータ移動用)
|
|
||||||
6. **A-2**: チェックボックス・一括操作
|
|
||||||
7. **A-1**: ダッシュボード画面
|
|
||||||
8. **A-7**: 検索・フィルタ
|
|
||||||
|
|
||||||
詳細は `document/06_ドキュメントvs実装_差異レポート.md` を参照
|
詳細は `document/06_ドキュメントvs実装_差異レポート.md` を参照
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ urlpatterns = [
|
|||||||
path('import/kyosai/', views.import_kyosai_master, name='import_kyosai'),
|
path('import/kyosai/', views.import_kyosai_master, name='import_kyosai'),
|
||||||
path('import/yoshida/', views.import_yoshida_fields, name='import_yoshida'),
|
path('import/yoshida/', views.import_yoshida_fields, name='import_yoshida'),
|
||||||
path('import/chusankan/', views.import_chusankan_master, name='import_chusankan'),
|
path('import/chusankan/', views.import_chusankan_master, name='import_chusankan'),
|
||||||
|
path('export/zip/', views.export_all_zip, name='export_all_zip'),
|
||||||
path('<int:field_id>/kyosai-links/', views.add_kyosai_links, name='add_kyosai_links'),
|
path('<int:field_id>/kyosai-links/', views.add_kyosai_links, name='add_kyosai_links'),
|
||||||
path('<int:field_id>/kyosai-links/<int:kyosai_id>/', views.remove_kyosai_link, name='remove_kyosai_link'),
|
path('<int:field_id>/kyosai-links/<int:kyosai_id>/', views.remove_kyosai_link, name='remove_kyosai_link'),
|
||||||
path('<int:field_id>/chusankan-links/', views.add_chusankan_links, name='add_chusankan_links'),
|
path('<int:field_id>/chusankan-links/', views.add_chusankan_links, name='add_chusankan_links'),
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import zipfile
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
from django.db import models as django_models
|
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.shortcuts import get_object_or_404
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
from django.views.decorators.http import require_http_methods
|
from django.views.decorators.http import require_http_methods
|
||||||
@@ -298,3 +302,79 @@ def import_chusankan_master(request):
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return JsonResponse({'error': str(e)}, status=500)
|
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
|
||||||
|
|||||||
@@ -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='品種'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -30,7 +30,7 @@ class Plan(models.Model):
|
|||||||
field = models.ForeignKey(Field, on_delete=models.CASCADE, related_name='plans', verbose_name="圃場")
|
field = models.ForeignKey(Field, on_delete=models.CASCADE, related_name='plans', verbose_name="圃場")
|
||||||
year = models.IntegerField(verbose_name="作付年度")
|
year = models.IntegerField(verbose_name="作付年度")
|
||||||
crop = models.ForeignKey(Crop, on_delete=models.CASCADE, related_name='plans', 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="備考")
|
notes = models.TextField(blank=True, null=True, verbose_name="備考")
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|||||||
@@ -30,47 +30,34 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### A-3: 前年度コピー機能(フロントエンド)
|
### ~~A-3: 前年度コピー機能(フロントエンド)~~ ✅ 対応済み
|
||||||
|
|
||||||
- **ドキュメント**: ユーザーストーリー P1-5、画面設計書 画面3 - [前年度をコピー]ボタン
|
- **対応内容**: 作付け計画画面(/allocation)の年度セレクタ横に[前年度コピー]ボタンを追加。確認ダイアログ付き、既存プランはスキップ(ignore_conflicts)
|
||||||
- **実装**: Backend API (`POST /api/plans/copy_from_previous_year/`) は存在するが、Frontend にボタンがない
|
- **対応日**: 2026-02-18
|
||||||
- **影響**: 毎年手動で39筆を設定する必要がある
|
|
||||||
- **状態**: 🔜 未着手
|
|
||||||
|
|
||||||
**対応方針**: 必要な項目です。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 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バックアップ
|
- **対応内容**: データ取込画面(/import)下部に「データエクスポート」セクション追加。全データ(圃場・共済・中山間・作付け計画・品種・M:N紐づけ)を6つのCSVファイルとしてZIPアーカイブでダウンロード。バックエンドAPI `GET /api/fields/export/zip/`
|
||||||
- **実装**: 未実装
|
- **対応日**: 2026-02-19
|
||||||
- **影響**: バックアップ手段がない(DBダンプのみ)
|
|
||||||
- **状態**: 🔜 未着手
|
|
||||||
|
|
||||||
**対応方針**: 必要です。近い将来サーバーに移行するので、その時に、このローカル環境で設定したデータを移動できるようにしたいです。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -221,18 +208,16 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### E-2: 対応付け可視化・紐づけ管理機能
|
### ~~E-2: 対応付け可視化・紐づけ管理機能~~ ✅ 対応済み
|
||||||
|
|
||||||
- **背景**: 3つのODSデータファイル(吉田農地台帳 → Field、水稲共済細目用 → OfficialKyosaiField、中山間 → OfficialChusankanField)間のM:N対応関係を確認・編集する手段がない
|
**対応日**: 2026-02-18
|
||||||
- **状態**: 🚧 一部実装済み
|
|
||||||
|
|
||||||
**実装済み:**
|
**対応内容:**
|
||||||
- ✅ バックエンドAPI 6本(共済/中山間マスタ一覧、紐づけ追加・解除)
|
- バックエンドAPI 6本(共済/中山間マスタ一覧、紐づけ追加・解除)
|
||||||
- ✅ 圃場詳細画面(/fields/[id]): +追加ボタン、×解除ボタン、検索付きモーダル、面積参考表示
|
- 圃場詳細画面(/fields/[id]): +追加ボタン、×解除ボタン、検索付きモーダル、面積参考表示
|
||||||
- ✅ 圃場一覧 通常モード: 「共済」「中山間」件数列
|
- 圃場一覧 通常モード: 「共済」「中山間」件数列
|
||||||
|
- 圃場一覧「対応表」モード: [通常]/[対応表]トグルで切替、圃場名・住所・面積・共済漢字地名・中山間所在地を一覧表示、直接紐づけ追加・解除可能
|
||||||
**未実装:**
|
- 共通コンポーネント: LinkModal(検索付き複数選択モーダル)を抽出
|
||||||
- 🔜 圃場一覧「対応表」モード: 漢字地名・所在地を一覧表示し、直接紐づけ追加・解除できる表示モード(仕様は画面設計書 画面4 に記載済み)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -242,14 +227,14 @@
|
|||||||
|---------|------|------|
|
|---------|------|------|
|
||||||
| A-1 | ダッシュボード画面 | 🔜 未着手 |
|
| A-1 | ダッシュボード画面 | 🔜 未着手 |
|
||||||
| A-2 | チェックボックス一括操作 | 🔜 未着手 |
|
| A-2 | チェックボックス一括操作 | 🔜 未着手 |
|
||||||
| A-3 | 前年度コピーボタン | 🔜 未着手 |
|
| A-3 | 前年度コピーボタン | ✅ 完了 |
|
||||||
| A-4 | 品種インライン追加・削除 | 🔜 未着手 |
|
| A-4 | 品種インライン追加・削除 | ✅ 完了 |
|
||||||
| A-5 | PDFプレビュー | 🔜 未着手 |
|
| A-5 | PDFプレビュー | ✅ 完了 |
|
||||||
| A-6 | エクスポート機能 | 🔜 未着手 |
|
| A-6 | エクスポート機能 | ✅ 完了 |
|
||||||
| A-7 | 検索・フィルタ | 🔜 未着手 |
|
| A-7 | 検索・フィルタ | 🔜 未着手 |
|
||||||
| A-8 | 圃場詳細 共済/中山間表示 | ✅ 完了 |
|
| A-8 | 圃場詳細 共済/中山間表示 | ✅ 完了 |
|
||||||
| B-1〜B-5 | ドキュメント追記 | ✅ 完了 |
|
| B-1〜B-5 | ドキュメント追記 | ✅ 完了 |
|
||||||
| C-1〜C-8 | ドキュメント/実装の食い違い修正 | ✅ 全件完了 |
|
| C-1〜C-8 | ドキュメント/実装の食い違い修正 | ✅ 全件完了 |
|
||||||
| D-1〜D-4 | 不具合修正 | ✅ 全件完了 |
|
| D-1〜D-4 | 不具合修正 | ✅ 全件完了 |
|
||||||
| E-1 | PDF帳票再設計 | ✅ 完了 |
|
| E-1 | PDF帳票再設計 | ✅ 完了 |
|
||||||
| E-2 | 対応付け可視化・紐づけ管理 | 🚧 一部実装済み(対応表モード未実装) |
|
| E-2 | 対応付け可視化・紐づけ管理 | ✅ 完了 |
|
||||||
|
|||||||
@@ -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 } from 'lucide-react';
|
import { Menu, X, BarChart3, ChevronRight, ChevronDown, ArrowUp, ArrowDown, Copy, Settings, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
interface SummaryItem {
|
interface SummaryItem {
|
||||||
cropId: number;
|
cropId: number;
|
||||||
@@ -30,6 +30,11 @@ export default function AllocationPage() {
|
|||||||
const [showMobileSummary, setShowMobileSummary] = useState(false);
|
const [showMobileSummary, setShowMobileSummary] = useState(false);
|
||||||
const [expandedCrops, setExpandedCrops] = useState<Set<number>>(new Set());
|
const [expandedCrops, setExpandedCrops] = useState<Set<number>>(new Set());
|
||||||
const [sortType, setSortType] = useState<SortType>('custom');
|
const [sortType, setSortType] = useState<SortType>('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<number | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData();
|
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 getVarietiesForCrop = (cropId: number) => {
|
||||||
const crop = crops.find((c) => c.id === cropId);
|
const crop = crops.find((c) => c.id === cropId);
|
||||||
return crop?.varieties || [];
|
return crop?.varieties || [];
|
||||||
@@ -436,6 +498,25 @@ export default function AllocationPage() {
|
|||||||
<option value={2026}>2026年</option>
|
<option value={2026}>2026年</option>
|
||||||
<option value={2027}>2027年</option>
|
<option value={2027}>2027年</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleCopyFromPreviousYear}
|
||||||
|
disabled={copying}
|
||||||
|
className="flex items-center px-3 py-2 text-sm border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 text-gray-700"
|
||||||
|
title={`${year - 1}年度の計画をコピー`}
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4 mr-1" />
|
||||||
|
{copying ? 'コピー中...' : '前年度コピー'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => { setShowVarietyManager(true); setManagerCropId(crops[0]?.id || null); }}
|
||||||
|
className="flex items-center px-3 py-2 text-sm border border-gray-300 rounded-md hover:bg-gray-50 text-gray-700"
|
||||||
|
title="品種管理"
|
||||||
|
>
|
||||||
|
<Settings className="h-4 w-4 mr-1" />
|
||||||
|
品種管理
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -557,23 +638,56 @@ export default function AllocationPage() {
|
|||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<select
|
{addingVariety?.fieldId === field.id ? (
|
||||||
value={selectedVarietyId || ''}
|
<div className="flex items-center gap-1">
|
||||||
onChange={(e) =>
|
<input
|
||||||
handleVarietyChange(field.id, e.target.value)
|
type="text"
|
||||||
}
|
value={newVarietyName}
|
||||||
disabled={
|
onChange={(e) => setNewVarietyName(e.target.value)}
|
||||||
saving === field.id || !selectedCropId
|
onKeyDown={(e) => {
|
||||||
}
|
if (e.key === 'Enter') handleAddVariety(field.id, addingVariety.cropId);
|
||||||
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 text-sm disabled:opacity-50 disabled:bg-gray-100"
|
if (e.key === 'Escape') { setAddingVariety(null); setNewVarietyName(''); }
|
||||||
>
|
}}
|
||||||
<option value="">選択してください</option>
|
placeholder="品種名を入力"
|
||||||
{getVarietiesForCrop(selectedCropId).map((variety) => (
|
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"
|
||||||
<option key={variety.id} value={variety.id}>
|
autoFocus
|
||||||
{variety.name}
|
/>
|
||||||
</option>
|
<button
|
||||||
))}
|
onClick={() => handleAddVariety(field.id, addingVariety.cropId)}
|
||||||
</select>
|
className="px-2 py-1.5 bg-green-600 text-white rounded text-xs hover:bg-green-700"
|
||||||
|
>
|
||||||
|
追加
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setAddingVariety(null); setNewVarietyName(''); }}
|
||||||
|
className="px-2 py-1.5 text-gray-500 hover:text-gray-700 text-xs"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<select
|
||||||
|
value={selectedVarietyId || ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.value === '__add__') {
|
||||||
|
setAddingVariety({ fieldId: field.id, cropId: selectedCropId });
|
||||||
|
setNewVarietyName('');
|
||||||
|
} else {
|
||||||
|
handleVarietyChange(field.id, e.target.value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={saving === field.id || !selectedCropId}
|
||||||
|
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 text-sm disabled:opacity-50 disabled:bg-gray-100"
|
||||||
|
>
|
||||||
|
<option value="">選択してください</option>
|
||||||
|
{getVarietiesForCrop(selectedCropId).map((variety) => (
|
||||||
|
<option key={variety.id} value={variety.id}>
|
||||||
|
{variety.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
{selectedCropId > 0 && <option value="__add__">+ 新しい品種を追加...</option>}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
<input
|
<input
|
||||||
@@ -683,6 +797,104 @@ export default function AllocationPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* 品種管理モーダル */}
|
||||||
|
{showVarietyManager && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-white rounded-lg shadow-xl w-full max-w-md max-h-[80vh] flex flex-col">
|
||||||
|
<div className="flex items-center justify-between p-4 border-b">
|
||||||
|
<h3 className="text-lg font-bold text-gray-900">品種管理</h3>
|
||||||
|
<button onClick={() => setShowVarietyManager(false)} className="text-gray-400 hover:text-gray-600">
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 border-b">
|
||||||
|
<label className="text-sm text-gray-600 mr-2">作物:</label>
|
||||||
|
<select
|
||||||
|
value={managerCropId || ''}
|
||||||
|
onChange={(e) => setManagerCropId(parseInt(e.target.value) || null)}
|
||||||
|
className="px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||||
|
>
|
||||||
|
{crops.map((crop) => (
|
||||||
|
<option key={crop.id} value={crop.id}>{crop.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
|
{managerCropId && getVarietiesForCrop(managerCropId).length > 0 ? (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{getVarietiesForCrop(managerCropId).map((v) => (
|
||||||
|
<li key={v.id} className="flex items-center justify-between p-2 rounded hover:bg-gray-50">
|
||||||
|
<span className="text-sm text-gray-900">{v.name}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteVariety(v.id, v.name)}
|
||||||
|
className="text-red-400 hover:text-red-600 p-1"
|
||||||
|
title="削除"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="text-gray-500 text-sm text-center py-4">品種が登録されていません</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 border-t">
|
||||||
|
<VarietyAddForm cropId={managerCropId} onAdd={async (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('品種の追加に失敗しました');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function VarietyAddForm({ cropId, onAdd }: { cropId: number | null; onAdd: (name: string) => Promise<void> }) {
|
||||||
|
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 (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={!cropId || !name.trim() || adding}
|
||||||
|
className="px-3 py-2 bg-green-600 text-white rounded-md text-sm hover:bg-green-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{adding ? '追加中...' : '追加'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useState, useRef } from 'react';
|
import { useState, useRef } from 'react';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import Navbar from '@/components/Navbar';
|
import Navbar from '@/components/Navbar';
|
||||||
import { Upload, Loader2, CheckCircle, XCircle } from 'lucide-react';
|
import { Upload, Download, Loader2, CheckCircle, XCircle } from 'lucide-react';
|
||||||
|
|
||||||
interface ImportResult {
|
interface ImportResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
@@ -20,10 +20,32 @@ export default function ImportPage() {
|
|||||||
const [kyosaiResult, setKyosaiResult] = useState<ImportResult | null>(null);
|
const [kyosaiResult, setKyosaiResult] = useState<ImportResult | null>(null);
|
||||||
const [yoshidaResult, setYoshidaResult] = useState<ImportResult | null>(null);
|
const [yoshidaResult, setYoshidaResult] = useState<ImportResult | null>(null);
|
||||||
const [chusankanResult, setChusankanResult] = useState<ImportResult | null>(null);
|
const [chusankanResult, setChusankanResult] = useState<ImportResult | null>(null);
|
||||||
|
const [exporting, setExporting] = useState(false);
|
||||||
const kyosaiInputRef = useRef<HTMLInputElement>(null);
|
const kyosaiInputRef = useRef<HTMLInputElement>(null);
|
||||||
const yoshidaInputRef = useRef<HTMLInputElement>(null);
|
const yoshidaInputRef = useRef<HTMLInputElement>(null);
|
||||||
const chusankanInputRef = useRef<HTMLInputElement>(null);
|
const chusankanInputRef = useRef<HTMLInputElement>(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 () => {
|
const handleKyosaiUpload = async () => {
|
||||||
if (!kyosaiFile) {
|
if (!kyosaiFile) {
|
||||||
alert('ファイルを選択してください');
|
alert('ファイルを選択してください');
|
||||||
@@ -399,6 +421,33 @@ export default function ImportPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* エクスポート */}
|
||||||
|
<div className="bg-white rounded-lg shadow p-6 border-t-4 border-gray-300">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
|
データエクスポート
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-600 mb-4">
|
||||||
|
全データ(圃場・共済・中山間・作付け計画・品種・紐づけ情報)をCSV形式のZIPファイルとしてダウンロードします。サーバー移行時のバックアップとして使用できます。
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={handleExportZip}
|
||||||
|
disabled={exporting}
|
||||||
|
className="w-full flex items-center justify-center px-4 py-2 bg-gray-700 text-white rounded-md hover:bg-gray-800 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
{exporting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-5 w-5 mr-2 animate-spin" />
|
||||||
|
エクスポート中...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Download className="h-5 w-5 mr-2" />
|
||||||
|
全データをZIPでダウンロード
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import Navbar from '@/components/Navbar';
|
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 downloadPdf = async (url: string, filename: string) => {
|
||||||
const response = await api.get(url, { responseType: 'blob' });
|
const response = await api.get(url, { responseType: 'blob' });
|
||||||
@@ -18,31 +18,35 @@ const downloadPdf = async (url: string, filename: string) => {
|
|||||||
window.URL.revokeObjectURL(downloadUrl);
|
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() {
|
export default function ReportsPage() {
|
||||||
const [year, setYear] = useState<number>(2025);
|
const [year, setYear] = useState<number>(2025);
|
||||||
const [downloading, setDownloading] = useState<string | null>(null);
|
const [busy, setBusy] = useState<string | null>(null);
|
||||||
|
|
||||||
const handleDownloadKyosai = async () => {
|
const handleAction = async (action: 'download' | 'preview', type: 'kyosai' | 'chusankan') => {
|
||||||
setDownloading('kyosai');
|
const key = `${action}-${type}`;
|
||||||
|
setBusy(key);
|
||||||
try {
|
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) {
|
} catch (error) {
|
||||||
console.error('Download failed:', error);
|
console.error(`${action} failed:`, error);
|
||||||
alert('ダウンロードに失敗しました');
|
alert(`${action === 'download' ? 'ダウンロード' : 'プレビュー'}に失敗しました`);
|
||||||
} finally {
|
} finally {
|
||||||
setDownloading(null);
|
setBusy(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);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -70,41 +74,63 @@ export default function ReportsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<button
|
{/* 水稲共済細目書 */}
|
||||||
onClick={handleDownloadKyosai}
|
<div className="border rounded-lg p-4">
|
||||||
disabled={downloading !== null}
|
<h3 className="font-medium text-gray-900 mb-3">水稲共済細目書</h3>
|
||||||
className="w-full flex items-center justify-center px-4 py-3 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
<div className="flex gap-3">
|
||||||
>
|
<button
|
||||||
{downloading === 'kyosai' ? (
|
onClick={() => handleAction('preview', 'kyosai')}
|
||||||
<>
|
disabled={busy !== null}
|
||||||
<Loader2 className="h-5 w-5 mr-2 animate-spin" />
|
className="flex-1 flex items-center justify-center px-4 py-2.5 border border-green-600 text-green-700 rounded-md hover:bg-green-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
ダウンロード中...
|
>
|
||||||
</>
|
{busy === 'preview-kyosai' ? (
|
||||||
) : (
|
<><Loader2 className="h-4 w-4 mr-2 animate-spin" />生成中...</>
|
||||||
<>
|
) : (
|
||||||
<FileDown className="h-5 w-5 mr-2" />
|
<><Eye className="h-4 w-4 mr-2" />プレビュー</>
|
||||||
水稲共済細目書をダウンロード
|
)}
|
||||||
</>
|
</button>
|
||||||
)}
|
<button
|
||||||
</button>
|
onClick={() => handleAction('download', 'kyosai')}
|
||||||
|
disabled={busy !== null}
|
||||||
|
className="flex-1 flex items-center justify-center px-4 py-2.5 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
{busy === 'download-kyosai' ? (
|
||||||
|
<><Loader2 className="h-4 w-4 mr-2 animate-spin" />ダウンロード中...</>
|
||||||
|
) : (
|
||||||
|
<><FileDown className="h-4 w-4 mr-2" />ダウンロード</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
{/* 中山間交付金申請書 */}
|
||||||
onClick={handleDownloadChusankan}
|
<div className="border rounded-lg p-4">
|
||||||
disabled={downloading !== null}
|
<h3 className="font-medium text-gray-900 mb-3">中山間交付金申請書</h3>
|
||||||
className="w-full flex items-center justify-center px-4 py-3 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
<div className="flex gap-3">
|
||||||
>
|
<button
|
||||||
{downloading === 'chusankan' ? (
|
onClick={() => handleAction('preview', 'chusankan')}
|
||||||
<>
|
disabled={busy !== null}
|
||||||
<Loader2 className="h-5 w-5 mr-2 animate-spin" />
|
className="flex-1 flex items-center justify-center px-4 py-2.5 border border-blue-600 text-blue-700 rounded-md hover:bg-blue-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
ダウンロード中...
|
>
|
||||||
</>
|
{busy === 'preview-chusankan' ? (
|
||||||
) : (
|
<><Loader2 className="h-4 w-4 mr-2 animate-spin" />生成中...</>
|
||||||
<>
|
) : (
|
||||||
<FileDown className="h-5 w-5 mr-2" />
|
<><Eye className="h-4 w-4 mr-2" />プレビュー</>
|
||||||
中山間交付金申請書をダウンロード
|
)}
|
||||||
</>
|
</button>
|
||||||
)}
|
<button
|
||||||
</button>
|
onClick={() => handleAction('download', 'chusankan')}
|
||||||
|
disabled={busy !== null}
|
||||||
|
className="flex-1 flex items-center justify-center px-4 py-2.5 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
{busy === 'download-chusankan' ? (
|
||||||
|
<><Loader2 className="h-4 w-4 mr-2 animate-spin" />ダウンロード中...</>
|
||||||
|
) : (
|
||||||
|
<><FileDown className="h-4 w-4 mr-2" />ダウンロード</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user