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:
@@ -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<Set<number>>(new Set());
|
||||
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(() => {
|
||||
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() {
|
||||
<option value={2026}>2026年</option>
|
||||
<option value={2027}>2027年</option>
|
||||
</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>
|
||||
|
||||
@@ -557,23 +638,56 @@ export default function AllocationPage() {
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<select
|
||||
value={selectedVarietyId || ''}
|
||||
onChange={(e) =>
|
||||
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>
|
||||
))}
|
||||
</select>
|
||||
{addingVariety?.fieldId === field.id ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
type="text"
|
||||
value={newVarietyName}
|
||||
onChange={(e) => 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
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleAddVariety(field.id, addingVariety.cropId)}
|
||||
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 className="px-6 py-4">
|
||||
<input
|
||||
@@ -683,6 +797,104 @@ export default function AllocationPage() {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<ImportResult | null>(null);
|
||||
const [yoshidaResult, setYoshidaResult] = useState<ImportResult | null>(null);
|
||||
const [chusankanResult, setChusankanResult] = useState<ImportResult | null>(null);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const kyosaiInputRef = useRef<HTMLInputElement>(null);
|
||||
const yoshidaInputRef = 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 () => {
|
||||
if (!kyosaiFile) {
|
||||
alert('ファイルを選択してください');
|
||||
@@ -399,6 +421,33 @@ export default function ImportPage() {
|
||||
</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>
|
||||
|
||||
@@ -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<number>(2025);
|
||||
const [downloading, setDownloading] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(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() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<button
|
||||
onClick={handleDownloadKyosai}
|
||||
disabled={downloading !== null}
|
||||
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"
|
||||
>
|
||||
{downloading === 'kyosai' ? (
|
||||
<>
|
||||
<Loader2 className="h-5 w-5 mr-2 animate-spin" />
|
||||
ダウンロード中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FileDown className="h-5 w-5 mr-2" />
|
||||
水稲共済細目書をダウンロード
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{/* 水稲共済細目書 */}
|
||||
<div className="border rounded-lg p-4">
|
||||
<h3 className="font-medium text-gray-900 mb-3">水稲共済細目書</h3>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => handleAction('preview', 'kyosai')}
|
||||
disabled={busy !== null}
|
||||
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" />生成中...</>
|
||||
) : (
|
||||
<><Eye className="h-4 w-4 mr-2" />プレビュー</>
|
||||
)}
|
||||
</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}
|
||||
disabled={downloading !== null}
|
||||
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"
|
||||
>
|
||||
{downloading === 'chusankan' ? (
|
||||
<>
|
||||
<Loader2 className="h-5 w-5 mr-2 animate-spin" />
|
||||
ダウンロード中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FileDown className="h-5 w-5 mr-2" />
|
||||
中山間交付金申請書をダウンロード
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{/* 中山間交付金申請書 */}
|
||||
<div className="border rounded-lg p-4">
|
||||
<h3 className="font-medium text-gray-900 mb-3">中山間交付金申請書</h3>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => handleAction('preview', 'chusankan')}
|
||||
disabled={busy !== null}
|
||||
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" />生成中...</>
|
||||
) : (
|
||||
<><Eye className="h-4 w-4 mr-2" />プレビュー</>
|
||||
)}
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user