実装完了

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:
Akira
2026-02-22 15:01:50 +09:00
parent 7c40480599
commit 04b1ca1bb9
8 changed files with 504 additions and 121 deletions

View File

@@ -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:*)"
]
}
}

View File

@@ -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:

View File

@@ -9,7 +9,12 @@ urlpatterns = [
# Windmill向けAPIAPIキー認証
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'),

View File

@@ -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,
})
# ---------------------------------------------------------------------------
# フィードバックビュー(認証不要)
# ---------------------------------------------------------------------------

View File

@@ -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",
]
# メールフィルタリング機能

View File

@@ -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,14 +74,22 @@ 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>
<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>
<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>
<select
value={year}
onChange={(e) => setYear(Number(e.target.value))}
className="px-3 py-2 border border-gray-300 rounded-md text-sm"
className="px-3 py-1.5 border border-gray-300 rounded-md text-sm"
>
{years.map((y) => (
<option key={y} value={y}>{y}</option>
@@ -61,30 +97,35 @@ export default function DashboardPage() {
</select>
</div>
{loading ? (
<div className="flex items-center justify-center py-16">
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
<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>
) : 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-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-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 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-white rounded-lg shadow p-5">
<p className="text-sm text-gray-500"></p>
<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-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 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-5 w-5 text-amber-500 ml-2" />
<AlertTriangle className="h-4 w-4 text-amber-500 ml-1 flex-shrink-0" />
)}
</div>
</div>
@@ -92,28 +133,21 @@ export default function DashboardPage() {
{/* 作物別集計 */}
{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>
<div>
<p className="text-xs font-medium text-gray-500 mb-2"></p>
<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 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">
<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 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>
@@ -121,46 +155,116 @@ export default function DashboardPage() {
)}
{/* クイックアクセス */}
<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">
<div className="grid grid-cols-2 gap-2 pt-2">
<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"
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-6 w-6 text-green-600 mb-2" />
<span className="text-sm text-gray-700"></span>
<Wheat className="h-4 w-4 text-green-600 flex-shrink-0" />
</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"
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-6 w-6 text-blue-600 mb-2" />
<span className="text-sm text-gray-700"></span>
<MapPin className="h-4 w-4 text-blue-600 flex-shrink-0" />
</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"
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-6 w-6 text-purple-600 mb-2" />
<span className="text-sm text-gray-700"></span>
<FileText className="h-4 w-4 text-purple-600 flex-shrink-0" />
</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"
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-6 w-6 text-orange-600 mb-2" />
<span className="text-sm text-gray-700"></span>
<Upload className="h-4 w-4 text-orange-600 flex-shrink-0" />
</button>
</div>
</div>
</div>
</>
) : (
<div className="text-center py-16 text-gray-500">
</div>
<div className="py-12 text-center text-gray-500 text-sm"></div>
)}
</div>
</div>
{/* ===== メール通知モジュール ===== */}
<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>
);
}

View 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>
);
}

View File

@@ -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>