diff --git a/.claude/settings.json b/.claude/settings.json index a1f4dc5..dfad4e3 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -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:*)" ] } } diff --git a/backend/apps/mail/serializers.py b/backend/apps/mail/serializers.py index 4fa64e2..8b330c6 100644 --- a/backend/apps/mail/serializers.py +++ b/backend/apps/mail/serializers.py @@ -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: diff --git a/backend/apps/mail/urls.py b/backend/apps/mail/urls.py index 3150a41..311d4ed 100644 --- a/backend/apps/mail/urls.py +++ b/backend/apps/mail/urls.py @@ -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//', views.FeedbackView.as_view(), name='mail-feedback'), diff --git a/backend/apps/mail/views.py b/backend/apps/mail/views.py index 8d496c6..d645009 100644 --- a/backend/apps/mail/views.py +++ b/backend/apps/mail/views.py @@ -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, + }) + + # --------------------------------------------------------------------------- # フィードバックビュー(認証不要) # --------------------------------------------------------------------------- diff --git a/backend/keinasystem/settings.py b/backend/keinasystem/settings.py index d034e7a..52d8dce 100644 --- a/backend/keinasystem/settings.py +++ b/backend/keinasystem/settings.py @@ -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", ] # メールフィルタリング機能 diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx index 341b33c..62671d8 100644 --- a/frontend/src/app/dashboard/page.tsx +++ b/frontend/src/app/dashboard/page.tsx @@ -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(null); - const [loading, setLoading] = useState(true); + const [summaryLoading, setSummaryLoading] = useState(true); + const [mailStats, setMailStats] = useState(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 (
-
- {/* ヘッダー */} -
-

ダッシュボード

- -
+
+

ダッシュボード

- {loading ? ( -
- -
- ) : summary ? ( -
- {/* 概要サマリーカード */} -
-
-

全圃場数

-

{summary.total_fields}

+
+ + {/* ===== 作付け計画モジュール ===== */} +
+
+
+ +

作付け計画

-
-

作付け済み

-

{summary.assigned_fields}

-
-
-

未割当

-
-

0 ? 'text-amber-500' : 'text-gray-400'}`}> - {summary.unassigned_fields} -

- {summary.unassigned_fields > 0 && ( - - )} + +
+ +
+ {summaryLoading ? ( +
+
-
-
+ ) : summary ? ( + <> + {/* 集計数値 */} +
+
+

全圃場数

+

+ {summary.total_fields} +

+
+
+

作付け済み

+

+ {summary.assigned_fields} +

+
+
+

未割当

+
+

0 ? 'text-amber-500' : 'text-gray-400'}`}> + {summary.unassigned_fields} +

+ {summary.unassigned_fields > 0 && ( + + )} +
+
+
- {/* 作物別集計 */} - {summary.by_crop.length > 0 && ( -
-

作物別集計

- - - - - - - - - - {summary.by_crop.map((item) => ( - - - - - - ))} - - - - - - -
作物筆数面積(反)
{item.crop}{item.count}{item.area.toFixed(1)}
合計{summary.total_plans}{summary.total_area.toFixed(1)}
-
- )} + {/* 作物別集計 */} + {summary.by_crop.length > 0 && ( +
+

作物別集計

+ + + {summary.by_crop.map((item) => ( + + + + + + ))} + + + + + + +
{item.crop}{item.count}筆{item.area.toFixed(1)}反
合計{summary.total_plans}筆{summary.total_area.toFixed(1)}反
+
+ )} - {/* クイックアクセス */} -
-

クイックアクセス

-
- - - - -
+ {/* クイックアクセス */} +
+ + + + +
+ + ) : ( +
データの取得に失敗しました
+ )}
- ) : ( -
- データの取得に失敗しました + + {/* ===== メール通知モジュール ===== */} +
+
+ +

メール通知

+
+ +
+ {mailLoading ? ( +
+ +
+ ) : mailStats ? ( + <> + {/* 統計 */} +
+
+

今日の処理

+

+ {mailStats.today_processed} +

+
+
+

LINE通知済み

+

+ {mailStats.today_notified} +

+
+
+

フィードバック待ち

+
+

0 ? 'text-amber-500' : 'text-gray-400'}`}> + {mailStats.feedback_pending} +

+ {mailStats.feedback_pending > 0 && ( + + )} +
+
+
+

送信者ルール

+

+ {mailStats.total_rules} +

+
+
+ + {/* クイックアクセス */} +
+ + +
+ + ) : ( +
データの取得に失敗しました
+ )} +
- )} + +
); diff --git a/frontend/src/app/mail/history/page.tsx b/frontend/src/app/mail/history/page.tsx new file mode 100644 index 0000000..c449a4a --- /dev/null +++ b/frontend/src/app/mail/history/page.tsx @@ -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 = { + important: '重要だった', + not_important: '普通のメール', + never_notify: '通知しない', + always_notify: '常に通知', +}; + +const ACCOUNT_LABELS: Record = { + gmail: 'Gmail', + hotmail: 'Hotmail', + xserver: 'Xserver', +}; + +export default function MailHistoryPage() { + const router = useRouter(); + const [emails, setEmails] = useState([]); + 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 ( +
+ +
+ {/* ヘッダー */} +
+
+ +

メール処理履歴

+
+ +
+ + {/* フィルター */} +
+ + +
+ + {/* テーブル */} + {loading ? ( +
+ +
+ ) : emails.length === 0 ? ( +
処理済みメールはありません
+ ) : ( +
+ + + + + + + + + + + + + {emails.map((email) => ( + + + + + + + + + ))} + +
受信日時送信者件名LLM判定フィードバック
+
{formatDate(email.received_at)}
+
{ACCOUNT_LABELS[email.account] ?? email.account}
+
+
{email.sender_email}
+
+
{email.subject}
+
+ {email.llm_verdict === 'important' ? ( + + 重要 + + ) : ( + + 通常 + + )} + + {email.feedback ? ( + + {FEEDBACK_LABELS[email.feedback] ?? email.feedback} + + ) : email.llm_verdict === 'important' ? ( + + 未回答 + + ) : ( + + )} + + {email.feedback_token && ( + + + 回答 + + )} +
+
+ 最新100件を表示 +
+
+ )} +
+
+ ); +} diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx index 3d74cdc..03a8dc2 100644 --- a/frontend/src/components/Navbar.tsx +++ b/frontend/src/components/Navbar.tsx @@ -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() { データ取込 +