実装完了
Backend(Django) backend/apps/mail/serializers.py MailEmailListSerializer を新規追加(フロントエンド向けメール一覧用) feedback_token フィールドを含む(フィードバックリンク表示用) backend/apps/mail/views.py MailEmailCreateView → MailEmailView に変更(GET+POST を統合) GET /api/mail/emails/ : JWT認証でメール履歴取得(最新100件、account/verdict フィルター対応) POST /api/mail/emails/ : APIキー認証でWindmillからのメール記録(既存動作を維持) get_permissions() でメソッドごとに認証方法を切替 MailStatsView を新規追加 GET /api/mail/stats/ : 今日の処理件数、LINE通知数、フィードバック待ち、ルール数を返す backend/apps/mail/urls.py emails/ → MailEmailView(GET+POST) stats/ → MailStatsView を追加 Frontend(Next.js) frontend/src/app/mail/history/page.tsx (新規作成) メール処理履歴の一覧テーブル アカウント・LLM判定でフィルタリング可能 LLM判定・フィードバック状態をバッジで表示 フィードバックトークンがあれば「回答」リンクを表示 frontend/src/app/dashboard/page.tsx (再設計) 2カラムのモジュールカード形式に変更 作付け計画カード: 年度セレクタ、集計数値、作物別集計、クイックアクセス メール通知カード: 今日の処理件数、LINE通知数、フィードバック待ち、ルール数、メール履歴・ルール管理ボタン
This commit is contained in:
@@ -10,7 +10,9 @@
|
||||
"Bash(__NEW_LINE_aab4b5b969b9f00e__ curl -sk -H \"Authorization: Bearer $TOKEN\" \"http://localhost/api/w/admins/jobs_u/get/$JOB_ID\")",
|
||||
"Bash(__NEW_LINE_200db6a80207b533__ curl -sk -H \"Authorization: Bearer $TOKEN\" \"http://localhost/api/w/admins/jobs_u/completed/get_result/$JOB_ID\")",
|
||||
"Bash(STEP_JOB=\"019c8344-4fc4-f59e-1009-c3733e28b46c\")",
|
||||
"Bash(__NEW_LINE_94a6a83a7608650e__ curl -sk -H \"Authorization: Bearer $TOKEN\" \"http://localhost/api/w/admins/jobs_u/completed/get/$STEP_JOB\")"
|
||||
"Bash(__NEW_LINE_94a6a83a7608650e__ curl -sk -H \"Authorization: Bearer $TOKEN\" \"http://localhost/api/w/admins/jobs_u/completed/get/$STEP_JOB\")",
|
||||
"Bash(docker start:*)",
|
||||
"Bash(docker stop:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,24 @@ class MailEmailCreateSerializer(serializers.ModelSerializer):
|
||||
]
|
||||
|
||||
|
||||
class MailEmailListSerializer(serializers.ModelSerializer):
|
||||
"""フロントエンド向けメール一覧用"""
|
||||
feedback_token = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = MailEmail
|
||||
fields = [
|
||||
'id', 'account', 'sender_email', 'sender_domain',
|
||||
'subject', 'received_at', 'llm_verdict',
|
||||
'notified_at', 'feedback', 'feedback_at', 'feedback_token',
|
||||
]
|
||||
|
||||
def get_feedback_token(self, obj):
|
||||
if hasattr(obj, 'notification_token'):
|
||||
return str(obj.notification_token.token)
|
||||
return None
|
||||
|
||||
|
||||
class FeedbackDetailSerializer(serializers.ModelSerializer):
|
||||
"""フィードバックページ表示用"""
|
||||
class Meta:
|
||||
|
||||
@@ -9,7 +9,12 @@ urlpatterns = [
|
||||
# Windmill向けAPI(APIキー認証)
|
||||
path('sender-rule/', views.SenderRuleView.as_view(), name='mail-sender-rule'),
|
||||
path('sender-context/', views.SenderContextView.as_view(), name='mail-sender-context'),
|
||||
path('emails/', views.MailEmailCreateView.as_view(), name='mail-email-create'),
|
||||
|
||||
# メール記録(POST: APIキー認証)&履歴取得(GET: JWT認証)
|
||||
path('emails/', views.MailEmailView.as_view(), name='mail-emails'),
|
||||
|
||||
# ダッシュボード用統計(JWT認証)
|
||||
path('stats/', views.MailStatsView.as_view(), name='mail-stats'),
|
||||
|
||||
# フィードバック(認証不要、UUIDトークン)
|
||||
path('feedback/<uuid:token>/', views.FeedbackView.as_view(), name='mail-feedback'),
|
||||
|
||||
@@ -12,6 +12,7 @@ from .models import MailSender, MailEmail, MailNotificationToken
|
||||
from .serializers import (
|
||||
MailSenderSerializer,
|
||||
MailEmailCreateSerializer,
|
||||
MailEmailListSerializer,
|
||||
FeedbackDetailSerializer,
|
||||
)
|
||||
|
||||
@@ -107,13 +108,29 @@ class SenderContextView(APIView):
|
||||
})
|
||||
|
||||
|
||||
class MailEmailCreateView(APIView):
|
||||
class MailEmailView(APIView):
|
||||
"""
|
||||
POST /api/mail/emails/
|
||||
メールを記録する。llm_verdict == 'important' の場合はトークンも発行する。
|
||||
GET /api/mail/emails/ メール処理履歴を取得(JWT認証)
|
||||
POST /api/mail/emails/ メールを記録する(APIキー認証、Windmill向け)
|
||||
"""
|
||||
permission_classes = [MailAPIKeyPermission]
|
||||
authentication_classes = []
|
||||
|
||||
def get_permissions(self):
|
||||
if self.request.method == 'POST':
|
||||
return [MailAPIKeyPermission()]
|
||||
return [IsAuthenticated()]
|
||||
|
||||
def get(self, request):
|
||||
qs = MailEmail.objects.select_related('notification_token').order_by('-received_at')
|
||||
|
||||
account = request.query_params.get('account')
|
||||
if account:
|
||||
qs = qs.filter(account=account)
|
||||
verdict = request.query_params.get('verdict')
|
||||
if verdict:
|
||||
qs = qs.filter(llm_verdict=verdict)
|
||||
|
||||
serializer = MailEmailListSerializer(qs[:100], many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
def post(self, request):
|
||||
serializer = MailEmailCreateSerializer(data=request.data)
|
||||
@@ -134,6 +151,31 @@ class MailEmailCreateView(APIView):
|
||||
return Response(response_data, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
class MailStatsView(APIView):
|
||||
"""
|
||||
GET /api/mail/stats/ ダッシュボード用統計
|
||||
"""
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
today = timezone.now().date()
|
||||
|
||||
today_processed = MailEmail.objects.filter(received_at__date=today).count()
|
||||
today_notified = MailEmail.objects.filter(notified_at__date=today).count()
|
||||
feedback_pending = MailEmail.objects.filter(
|
||||
llm_verdict='important',
|
||||
feedback__isnull=True
|
||||
).count()
|
||||
total_rules = MailSender.objects.count()
|
||||
|
||||
return Response({
|
||||
'today_processed': today_processed,
|
||||
'today_notified': today_notified,
|
||||
'feedback_pending': feedback_pending,
|
||||
'total_rules': total_rules,
|
||||
})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# フィードバックビュー(認証不要)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -148,6 +148,8 @@ SIMPLE_JWT = {
|
||||
CORS_ALLOWED_ORIGINS = [
|
||||
"http://localhost:3000",
|
||||
"http://127.0.0.1:3000",
|
||||
"http://localhost:3001",
|
||||
"http://127.0.0.1:3001",
|
||||
]
|
||||
|
||||
# メールフィルタリング機能
|
||||
|
||||
@@ -4,7 +4,11 @@ import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { api } from '@/lib/api';
|
||||
import Navbar from '@/components/Navbar';
|
||||
import { Wheat, MapPin, FileText, Upload, Loader2, AlertTriangle } from 'lucide-react';
|
||||
import {
|
||||
Wheat, MapPin, FileText, Upload,
|
||||
Loader2, AlertTriangle,
|
||||
Mail, Clock, Shield, History,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface SummaryData {
|
||||
year: number;
|
||||
@@ -16,28 +20,52 @@ interface SummaryData {
|
||||
by_crop: { crop: string; count: number; area: number }[];
|
||||
}
|
||||
|
||||
interface MailStats {
|
||||
today_processed: number;
|
||||
today_notified: number;
|
||||
feedback_pending: number;
|
||||
total_rules: number;
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const router = useRouter();
|
||||
const currentYear = new Date().getFullYear();
|
||||
const [year, setYear] = useState(currentYear);
|
||||
const [summary, setSummary] = useState<SummaryData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [summaryLoading, setSummaryLoading] = useState(true);
|
||||
const [mailStats, setMailStats] = useState<MailStats | null>(null);
|
||||
const [mailLoading, setMailLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSummary = async () => {
|
||||
setLoading(true);
|
||||
setSummaryLoading(true);
|
||||
try {
|
||||
const res = await api.get(`/plans/summary/?year=${year}`);
|
||||
setSummary(res.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch summary:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setSummaryLoading(false);
|
||||
}
|
||||
};
|
||||
fetchSummary();
|
||||
}, [year]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchMailStats = async () => {
|
||||
setMailLoading(true);
|
||||
try {
|
||||
const res = await api.get('/mail/stats/');
|
||||
setMailStats(res.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch mail stats:', error);
|
||||
} finally {
|
||||
setMailLoading(false);
|
||||
}
|
||||
};
|
||||
fetchMailStats();
|
||||
}, []);
|
||||
|
||||
const years = [];
|
||||
for (let y = currentYear + 1; y >= currentYear - 3; y--) {
|
||||
years.push(y);
|
||||
@@ -46,120 +74,196 @@ export default function DashboardPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<Navbar />
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* ヘッダー */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">ダッシュボード</h1>
|
||||
<select
|
||||
value={year}
|
||||
onChange={(e) => setYear(Number(e.target.value))}
|
||||
className="px-3 py-2 border border-gray-300 rounded-md text-sm"
|
||||
>
|
||||
{years.map((y) => (
|
||||
<option key={y} value={y}>{y}年度</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">ダッシュボード</h1>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
|
||||
</div>
|
||||
) : summary ? (
|
||||
<div className="space-y-6">
|
||||
{/* 概要サマリーカード */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="bg-white rounded-lg shadow p-5">
|
||||
<p className="text-sm text-gray-500">全圃場数</p>
|
||||
<p className="text-3xl font-bold text-gray-900 mt-1">{summary.total_fields}<span className="text-base font-normal text-gray-500 ml-1">筆</span></p>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
|
||||
{/* ===== 作付け計画モジュール ===== */}
|
||||
<div className="bg-white rounded-lg shadow flex flex-col">
|
||||
<div className="px-5 py-4 border-b border-gray-200 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Wheat className="h-5 w-5 text-green-600" />
|
||||
<h2 className="text-base font-semibold text-gray-900">作付け計画</h2>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-5">
|
||||
<p className="text-sm text-gray-500">作付け済み</p>
|
||||
<p className="text-3xl font-bold text-green-600 mt-1">{summary.assigned_fields}<span className="text-base font-normal text-gray-500 ml-1">筆</span></p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-5">
|
||||
<p className="text-sm text-gray-500">未割当</p>
|
||||
<div className="flex items-center mt-1">
|
||||
<p className={`text-3xl font-bold ${summary.unassigned_fields > 0 ? 'text-amber-500' : 'text-gray-400'}`}>
|
||||
{summary.unassigned_fields}<span className="text-base font-normal text-gray-500 ml-1">筆</span>
|
||||
</p>
|
||||
{summary.unassigned_fields > 0 && (
|
||||
<AlertTriangle className="h-5 w-5 text-amber-500 ml-2" />
|
||||
)}
|
||||
<select
|
||||
value={year}
|
||||
onChange={(e) => setYear(Number(e.target.value))}
|
||||
className="px-3 py-1.5 border border-gray-300 rounded-md text-sm"
|
||||
>
|
||||
{years.map((y) => (
|
||||
<option key={y} value={y}>{y}年度</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="p-5 space-y-4 flex-1">
|
||||
{summaryLoading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : summary ? (
|
||||
<>
|
||||
{/* 集計数値 */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="bg-gray-50 rounded-lg p-3">
|
||||
<p className="text-xs text-gray-500">全圃場数</p>
|
||||
<p className="text-2xl font-bold text-gray-900 mt-1">
|
||||
{summary.total_fields}<span className="text-xs font-normal text-gray-500 ml-1">筆</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-lg p-3">
|
||||
<p className="text-xs text-gray-500">作付け済み</p>
|
||||
<p className="text-2xl font-bold text-green-600 mt-1">
|
||||
{summary.assigned_fields}<span className="text-xs font-normal text-gray-500 ml-1">筆</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-lg p-3">
|
||||
<p className="text-xs text-gray-500">未割当</p>
|
||||
<div className="flex items-center mt-1">
|
||||
<p className={`text-2xl font-bold ${summary.unassigned_fields > 0 ? 'text-amber-500' : 'text-gray-400'}`}>
|
||||
{summary.unassigned_fields}<span className="text-xs font-normal text-gray-500 ml-1">筆</span>
|
||||
</p>
|
||||
{summary.unassigned_fields > 0 && (
|
||||
<AlertTriangle className="h-4 w-4 text-amber-500 ml-1 flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 作物別集計 */}
|
||||
{summary.by_crop.length > 0 && (
|
||||
<div className="bg-white rounded-lg shadow p-5">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-3">作物別集計</h2>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200">
|
||||
<th className="text-left py-2 text-gray-500 font-medium">作物</th>
|
||||
<th className="text-right py-2 text-gray-500 font-medium">筆数</th>
|
||||
<th className="text-right py-2 text-gray-500 font-medium">面積(反)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{summary.by_crop.map((item) => (
|
||||
<tr key={item.crop} className="border-b border-gray-100">
|
||||
<td className="py-2 text-gray-900">{item.crop}</td>
|
||||
<td className="py-2 text-right text-gray-700">{item.count}</td>
|
||||
<td className="py-2 text-right text-gray-700">{item.area.toFixed(1)}</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr className="font-semibold">
|
||||
<td className="py-2 text-gray-900">合計</td>
|
||||
<td className="py-2 text-right text-gray-900">{summary.total_plans}</td>
|
||||
<td className="py-2 text-right text-gray-900">{summary.total_area.toFixed(1)}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{/* 作物別集計 */}
|
||||
{summary.by_crop.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500 mb-2">作物別集計</p>
|
||||
<table className="w-full text-sm">
|
||||
<tbody>
|
||||
{summary.by_crop.map((item) => (
|
||||
<tr key={item.crop} className="border-b border-gray-100 last:border-0">
|
||||
<td className="py-1.5 text-gray-900">{item.crop}</td>
|
||||
<td className="py-1.5 text-right text-gray-600">{item.count}筆</td>
|
||||
<td className="py-1.5 text-right text-gray-600">{item.area.toFixed(1)}反</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr className="font-semibold border-t border-gray-200">
|
||||
<td className="pt-2 text-gray-900">合計</td>
|
||||
<td className="pt-2 text-right text-gray-900">{summary.total_plans}筆</td>
|
||||
<td className="pt-2 text-right text-gray-900">{summary.total_area.toFixed(1)}反</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* クイックアクセス */}
|
||||
<div className="bg-white rounded-lg shadow p-5">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-3">クイックアクセス</h2>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<button
|
||||
onClick={() => router.push('/allocation')}
|
||||
className="flex flex-col items-center p-4 rounded-lg border border-gray-200 hover:bg-green-50 hover:border-green-300 transition-colors"
|
||||
>
|
||||
<Wheat className="h-6 w-6 text-green-600 mb-2" />
|
||||
<span className="text-sm text-gray-700">作付け計画</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => router.push('/fields')}
|
||||
className="flex flex-col items-center p-4 rounded-lg border border-gray-200 hover:bg-blue-50 hover:border-blue-300 transition-colors"
|
||||
>
|
||||
<MapPin className="h-6 w-6 text-blue-600 mb-2" />
|
||||
<span className="text-sm text-gray-700">圃場管理</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => router.push('/reports')}
|
||||
className="flex flex-col items-center p-4 rounded-lg border border-gray-200 hover:bg-purple-50 hover:border-purple-300 transition-colors"
|
||||
>
|
||||
<FileText className="h-6 w-6 text-purple-600 mb-2" />
|
||||
<span className="text-sm text-gray-700">帳票出力</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => router.push('/import')}
|
||||
className="flex flex-col items-center p-4 rounded-lg border border-gray-200 hover:bg-orange-50 hover:border-orange-300 transition-colors"
|
||||
>
|
||||
<Upload className="h-6 w-6 text-orange-600 mb-2" />
|
||||
<span className="text-sm text-gray-700">データ取込</span>
|
||||
</button>
|
||||
</div>
|
||||
{/* クイックアクセス */}
|
||||
<div className="grid grid-cols-2 gap-2 pt-2">
|
||||
<button
|
||||
onClick={() => router.push('/allocation')}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-md border border-gray-200 hover:bg-green-50 hover:border-green-300 transition-colors text-sm text-gray-700"
|
||||
>
|
||||
<Wheat className="h-4 w-4 text-green-600 flex-shrink-0" />
|
||||
作付け計画
|
||||
</button>
|
||||
<button
|
||||
onClick={() => router.push('/fields')}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-md border border-gray-200 hover:bg-blue-50 hover:border-blue-300 transition-colors text-sm text-gray-700"
|
||||
>
|
||||
<MapPin className="h-4 w-4 text-blue-600 flex-shrink-0" />
|
||||
圃場管理
|
||||
</button>
|
||||
<button
|
||||
onClick={() => router.push('/reports')}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-md border border-gray-200 hover:bg-purple-50 hover:border-purple-300 transition-colors text-sm text-gray-700"
|
||||
>
|
||||
<FileText className="h-4 w-4 text-purple-600 flex-shrink-0" />
|
||||
帳票出力
|
||||
</button>
|
||||
<button
|
||||
onClick={() => router.push('/import')}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-md border border-gray-200 hover:bg-orange-50 hover:border-orange-300 transition-colors text-sm text-gray-700"
|
||||
>
|
||||
<Upload className="h-4 w-4 text-orange-600 flex-shrink-0" />
|
||||
データ取込
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="py-12 text-center text-gray-500 text-sm">データの取得に失敗しました</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-16 text-gray-500">
|
||||
データの取得に失敗しました
|
||||
|
||||
{/* ===== メール通知モジュール ===== */}
|
||||
<div className="bg-white rounded-lg shadow flex flex-col">
|
||||
<div className="px-5 py-4 border-b border-gray-200 flex items-center gap-2">
|
||||
<Mail className="h-5 w-5 text-blue-600" />
|
||||
<h2 className="text-base font-semibold text-gray-900">メール通知</h2>
|
||||
</div>
|
||||
|
||||
<div className="p-5 space-y-4 flex-1">
|
||||
{mailLoading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-gray-400" />
|
||||
</div>
|
||||
) : mailStats ? (
|
||||
<>
|
||||
{/* 統計 */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="bg-gray-50 rounded-lg p-3">
|
||||
<p className="text-xs text-gray-500">今日の処理</p>
|
||||
<p className="text-2xl font-bold text-gray-900 mt-1">
|
||||
{mailStats.today_processed}<span className="text-xs font-normal text-gray-500 ml-1">件</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-lg p-3">
|
||||
<p className="text-xs text-gray-500">LINE通知済み</p>
|
||||
<p className="text-2xl font-bold text-blue-600 mt-1">
|
||||
{mailStats.today_notified}<span className="text-xs font-normal text-gray-500 ml-1">件</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-lg p-3">
|
||||
<p className="text-xs text-gray-500">フィードバック待ち</p>
|
||||
<div className="flex items-center mt-1">
|
||||
<p className={`text-2xl font-bold ${mailStats.feedback_pending > 0 ? 'text-amber-500' : 'text-gray-400'}`}>
|
||||
{mailStats.feedback_pending}<span className="text-xs font-normal text-gray-500 ml-1">件</span>
|
||||
</p>
|
||||
{mailStats.feedback_pending > 0 && (
|
||||
<Clock className="h-4 w-4 text-amber-500 ml-1 flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-lg p-3">
|
||||
<p className="text-xs text-gray-500">送信者ルール</p>
|
||||
<p className="text-2xl font-bold text-gray-900 mt-1">
|
||||
{mailStats.total_rules}<span className="text-xs font-normal text-gray-500 ml-1">件</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* クイックアクセス */}
|
||||
<div className="grid grid-cols-2 gap-2 pt-2">
|
||||
<button
|
||||
onClick={() => router.push('/mail/history')}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-md border border-gray-200 hover:bg-blue-50 hover:border-blue-300 transition-colors text-sm text-gray-700"
|
||||
>
|
||||
<History className="h-4 w-4 text-blue-600 flex-shrink-0" />
|
||||
メール履歴
|
||||
</button>
|
||||
<button
|
||||
onClick={() => router.push('/mail/rules')}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-md border border-gray-200 hover:bg-blue-50 hover:border-blue-300 transition-colors text-sm text-gray-700"
|
||||
>
|
||||
<Shield className="h-4 w-4 text-blue-600 flex-shrink-0" />
|
||||
ルール管理
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="py-12 text-center text-gray-500 text-sm">データの取得に失敗しました</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
199
frontend/src/app/mail/history/page.tsx
Normal file
199
frontend/src/app/mail/history/page.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { api } from '@/lib/api';
|
||||
import Navbar from '@/components/Navbar';
|
||||
import { Mail, Loader2, ExternalLink, CheckCircle, XCircle, Clock, Shield } from 'lucide-react';
|
||||
|
||||
interface MailEmail {
|
||||
id: number;
|
||||
account: string;
|
||||
sender_email: string;
|
||||
sender_domain: string;
|
||||
subject: string;
|
||||
received_at: string;
|
||||
llm_verdict: 'important' | 'not_important';
|
||||
notified_at: string | null;
|
||||
feedback: 'important' | 'not_important' | 'never_notify' | 'always_notify' | null;
|
||||
feedback_at: string | null;
|
||||
feedback_token: string | null;
|
||||
}
|
||||
|
||||
const FEEDBACK_LABELS: Record<string, string> = {
|
||||
important: '重要だった',
|
||||
not_important: '普通のメール',
|
||||
never_notify: '通知しない',
|
||||
always_notify: '常に通知',
|
||||
};
|
||||
|
||||
const ACCOUNT_LABELS: Record<string, string> = {
|
||||
gmail: 'Gmail',
|
||||
hotmail: 'Hotmail',
|
||||
xserver: 'Xserver',
|
||||
};
|
||||
|
||||
export default function MailHistoryPage() {
|
||||
const router = useRouter();
|
||||
const [emails, setEmails] = useState<MailEmail[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filterAccount, setFilterAccount] = useState('');
|
||||
const [filterVerdict, setFilterVerdict] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const fetchEmails = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (filterAccount) params.set('account', filterAccount);
|
||||
if (filterVerdict) params.set('verdict', filterVerdict);
|
||||
const res = await api.get(`/mail/emails/?${params.toString()}`);
|
||||
setEmails(res.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch emails:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchEmails();
|
||||
}, [filterAccount, filterVerdict]);
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleString('ja-JP', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<Navbar />
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* ヘッダー */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Mail className="h-6 w-6 text-blue-600" />
|
||||
<h1 className="text-2xl font-bold text-gray-900">メール処理履歴</h1>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => router.push('/mail/rules')}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm bg-white border border-gray-300 rounded-md hover:bg-gray-50 transition-colors text-gray-700"
|
||||
>
|
||||
<Shield className="h-4 w-4" />
|
||||
ルール管理
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* フィルター */}
|
||||
<div className="bg-white rounded-lg shadow p-4 mb-4 flex gap-3">
|
||||
<select
|
||||
value={filterAccount}
|
||||
onChange={(e) => setFilterAccount(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-md text-sm"
|
||||
>
|
||||
<option value="">全アカウント</option>
|
||||
<option value="gmail">Gmail</option>
|
||||
<option value="hotmail">Hotmail</option>
|
||||
<option value="xserver">Xserver</option>
|
||||
</select>
|
||||
<select
|
||||
value={filterVerdict}
|
||||
onChange={(e) => setFilterVerdict(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-md text-sm"
|
||||
>
|
||||
<option value="">全判定</option>
|
||||
<option value="important">重要</option>
|
||||
<option value="not_important">通常</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* テーブル */}
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-16">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
|
||||
</div>
|
||||
) : emails.length === 0 ? (
|
||||
<div className="text-center py-16 text-gray-500">処理済みメールはありません</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 text-gray-500 font-medium whitespace-nowrap">受信日時</th>
|
||||
<th className="text-left px-4 py-3 text-gray-500 font-medium">送信者</th>
|
||||
<th className="text-left px-4 py-3 text-gray-500 font-medium">件名</th>
|
||||
<th className="text-center px-4 py-3 text-gray-500 font-medium whitespace-nowrap">LLM判定</th>
|
||||
<th className="text-center px-4 py-3 text-gray-500 font-medium whitespace-nowrap">フィードバック</th>
|
||||
<th className="text-center px-4 py-3 text-gray-500 font-medium"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{emails.map((email) => (
|
||||
<tr key={email.id} className="border-b border-gray-100 hover:bg-gray-50">
|
||||
<td className="px-4 py-3 text-gray-600 whitespace-nowrap">
|
||||
<div>{formatDate(email.received_at)}</div>
|
||||
<div className="text-xs text-gray-400">{ACCOUNT_LABELS[email.account] ?? email.account}</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="text-gray-900 text-xs truncate max-w-[180px]">{email.sender_email}</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-900 max-w-xs">
|
||||
<div className="truncate">{email.subject}</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
{email.llm_verdict === 'important' ? (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-full bg-red-50 text-red-700 text-xs font-medium">
|
||||
<CheckCircle className="h-3 w-3" />重要
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-full bg-gray-100 text-gray-500 text-xs">
|
||||
<XCircle className="h-3 w-3" />通常
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
{email.feedback ? (
|
||||
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
|
||||
email.feedback === 'important' ? 'bg-green-50 text-green-700' :
|
||||
email.feedback === 'not_important' ? 'bg-gray-100 text-gray-600' :
|
||||
email.feedback === 'never_notify' ? 'bg-orange-50 text-orange-700' :
|
||||
'bg-blue-50 text-blue-700'
|
||||
}`}>
|
||||
{FEEDBACK_LABELS[email.feedback] ?? email.feedback}
|
||||
</span>
|
||||
) : email.llm_verdict === 'important' ? (
|
||||
<span className="inline-flex items-center gap-1 text-amber-500 text-xs">
|
||||
<Clock className="h-3 w-3" />未回答
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-400 text-xs">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
{email.feedback_token && (
|
||||
<a
|
||||
href={`/mail/feedback/${email.feedback_token}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-blue-600 hover:text-blue-800 text-xs"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
回答
|
||||
</a>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="px-4 py-3 text-xs text-gray-400 border-t border-gray-100">
|
||||
最新100件を表示
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter, usePathname } from 'next/navigation';
|
||||
import { LogOut, Wheat, MapPin, FileText, Upload, LayoutDashboard, Mail } from 'lucide-react';
|
||||
import { LogOut, Wheat, MapPin, FileText, Upload, LayoutDashboard, Mail, History, Shield } from 'lucide-react';
|
||||
import { logout } from '@/lib/api';
|
||||
|
||||
export default function Navbar() {
|
||||
@@ -79,14 +79,25 @@ export default function Navbar() {
|
||||
データ取込
|
||||
</button>
|
||||
<button
|
||||
onClick={() => router.push('/mail/rules')}
|
||||
onClick={() => router.push('/mail/history')}
|
||||
className={`flex items-center px-3 py-2 text-sm rounded-md transition-colors ${
|
||||
pathname?.startsWith('/mail/')
|
||||
isActive('/mail/history')
|
||||
? 'text-green-700 bg-green-50'
|
||||
: 'text-gray-700 hover:text-gray-900 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<Mail className="h-4 w-4 mr-2" />
|
||||
<History className="h-4 w-4 mr-2" />
|
||||
メール履歴
|
||||
</button>
|
||||
<button
|
||||
onClick={() => router.push('/mail/rules')}
|
||||
className={`flex items-center px-3 py-2 text-sm rounded-md transition-colors ${
|
||||
isActive('/mail/rules')
|
||||
? 'text-green-700 bg-green-50'
|
||||
: 'text-gray-700 hover:text-gray-900 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<Shield className="h-4 w-4 mr-2" />
|
||||
メールルール
|
||||
</button>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user