メール処理履歴画面にフィードバック機能を追加
- 全メール(重要・通常問わず)に対してフィードバックボタンを追加 - PATCH /api/mail/emails/<pk>/feedback/ エンドポイントを追加(JWT認証) - フィードバックモーダル: 重要/普通/今後通知しない/常に通知 の4択 - never_notify/always_notify 選択時はアドレス/ドメインの適用範囲を選択可能 - gmail_service アカウントのフィルタオプションを追加 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,8 @@ urlpatterns = [
|
|||||||
|
|
||||||
# メール記録(POST: APIキー認証)&履歴取得(GET: JWT認証)
|
# メール記録(POST: APIキー認証)&履歴取得(GET: JWT認証)
|
||||||
path('emails/', views.MailEmailView.as_view(), name='mail-emails'),
|
path('emails/', views.MailEmailView.as_view(), name='mail-emails'),
|
||||||
|
# 履歴画面からのフィードバック更新(JWT認証)
|
||||||
|
path('emails/<int:pk>/feedback/', views.MailEmailFeedbackView.as_view(), name='mail-email-feedback'),
|
||||||
|
|
||||||
# ダッシュボード用統計(JWT認証)
|
# ダッシュボード用統計(JWT認証)
|
||||||
path('stats/', views.MailStatsView.as_view(), name='mail-stats'),
|
path('stats/', views.MailStatsView.as_view(), name='mail-stats'),
|
||||||
|
|||||||
@@ -230,6 +230,47 @@ class FeedbackView(APIView):
|
|||||||
return Response({'status': 'ok'})
|
return Response({'status': 'ok'})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 履歴画面からのフィードバック更新(JWT認証)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class MailEmailFeedbackView(APIView):
|
||||||
|
"""
|
||||||
|
PATCH /api/mail/emails/<pk>/feedback/ 履歴画面から直接フィードバックを更新(JWT認証)
|
||||||
|
"""
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
def patch(self, request, pk):
|
||||||
|
mail_email = get_object_or_404(MailEmail, pk=pk)
|
||||||
|
|
||||||
|
feedback = request.data.get('feedback')
|
||||||
|
valid_feedbacks = ['important', 'not_important', 'never_notify', 'always_notify']
|
||||||
|
if feedback not in valid_feedbacks:
|
||||||
|
return Response(
|
||||||
|
{'error': f'feedback は {valid_feedbacks} のいずれかを指定してください'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
mail_email.feedback = feedback
|
||||||
|
mail_email.feedback_at = timezone.now()
|
||||||
|
mail_email.save(update_fields=['feedback', 'feedback_at'])
|
||||||
|
|
||||||
|
if feedback in ('never_notify', 'always_notify'):
|
||||||
|
scope = request.data.get('scope')
|
||||||
|
if scope == 'address':
|
||||||
|
MailSender.objects.update_or_create(
|
||||||
|
email=mail_email.sender_email,
|
||||||
|
defaults={'domain': None, 'rule': feedback}
|
||||||
|
)
|
||||||
|
elif scope == 'domain':
|
||||||
|
MailSender.objects.update_or_create(
|
||||||
|
domain=mail_email.sender_domain,
|
||||||
|
defaults={'email': None, 'rule': feedback}
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response({'status': 'ok', 'feedback': feedback})
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# ルール管理(JWT認証)
|
# ルール管理(JWT認証)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useState, useEffect } from 'react';
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import Navbar from '@/components/Navbar';
|
import Navbar from '@/components/Navbar';
|
||||||
import { Mail, Loader2, ExternalLink, CheckCircle, XCircle, Clock, Shield } from 'lucide-react';
|
import { Mail, Loader2, CheckCircle, XCircle, Clock, Shield, MessageSquare } from 'lucide-react';
|
||||||
|
|
||||||
interface MailEmail {
|
interface MailEmail {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -34,6 +34,13 @@ const ACCOUNT_LABELS: Record<string, string> = {
|
|||||||
gmail_service: 'Gmail (サービス用)',
|
gmail_service: 'Gmail (サービス用)',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const FEEDBACK_OPTIONS = [
|
||||||
|
{ value: 'important', label: '重要だった', color: 'bg-green-50 text-green-700 border-green-200 hover:bg-green-100' },
|
||||||
|
{ value: 'not_important',label: '普通のメール', color: 'bg-gray-50 text-gray-600 border-gray-200 hover:bg-gray-100' },
|
||||||
|
{ value: 'never_notify', label: '今後通知しない', color: 'bg-orange-50 text-orange-700 border-orange-200 hover:bg-orange-100' },
|
||||||
|
{ value: 'always_notify',label: '常に通知してほしい', color: 'bg-blue-50 text-blue-700 border-blue-200 hover:bg-blue-100' },
|
||||||
|
];
|
||||||
|
|
||||||
export default function MailHistoryPage() {
|
export default function MailHistoryPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [emails, setEmails] = useState<MailEmail[]>([]);
|
const [emails, setEmails] = useState<MailEmail[]>([]);
|
||||||
@@ -41,6 +48,12 @@ export default function MailHistoryPage() {
|
|||||||
const [filterAccount, setFilterAccount] = useState('');
|
const [filterAccount, setFilterAccount] = useState('');
|
||||||
const [filterVerdict, setFilterVerdict] = useState('');
|
const [filterVerdict, setFilterVerdict] = useState('');
|
||||||
|
|
||||||
|
// フィードバックモーダル
|
||||||
|
const [feedbackModal, setFeedbackModal] = useState<MailEmail | null>(null);
|
||||||
|
const [pendingFeedback, setPendingFeedback] = useState('');
|
||||||
|
const [pendingScope, setPendingScope] = useState<'address' | 'domain'>('address');
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchEmails = async () => {
|
const fetchEmails = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -68,6 +81,41 @@ export default function MailHistoryPage() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openFeedbackModal = (email: MailEmail) => {
|
||||||
|
setFeedbackModal(email);
|
||||||
|
setPendingFeedback(email.feedback ?? '');
|
||||||
|
setPendingScope('address');
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeFeedbackModal = () => {
|
||||||
|
setFeedbackModal(null);
|
||||||
|
setPendingFeedback('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitFeedback = async () => {
|
||||||
|
if (!feedbackModal || !pendingFeedback) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const body: Record<string, string> = { feedback: pendingFeedback };
|
||||||
|
if (['never_notify', 'always_notify'].includes(pendingFeedback)) {
|
||||||
|
body.scope = pendingScope;
|
||||||
|
}
|
||||||
|
await api.patch(`/mail/emails/${feedbackModal.id}/feedback/`, body);
|
||||||
|
setEmails(prev => prev.map(e =>
|
||||||
|
e.id === feedbackModal.id
|
||||||
|
? { ...e, feedback: pendingFeedback as MailEmail['feedback'], feedback_at: new Date().toISOString() }
|
||||||
|
: e
|
||||||
|
));
|
||||||
|
closeFeedbackModal();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to submit feedback:', error);
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const needsScope = ['never_notify', 'always_notify'].includes(pendingFeedback);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
<Navbar />
|
<Navbar />
|
||||||
@@ -128,7 +176,6 @@ export default function MailHistoryPage() {
|
|||||||
<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">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 whitespace-nowrap">フィードバック</th>
|
||||||
<th className="text-center px-4 py-3 text-gray-500 font-medium"></th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -156,10 +203,11 @@ export default function MailHistoryPage() {
|
|||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-center">
|
<td className="px-4 py-3 text-center">
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
{email.feedback ? (
|
{email.feedback ? (
|
||||||
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
|
<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 === 'important' ? 'bg-green-50 text-green-700' :
|
||||||
email.feedback === 'not_important' ? 'bg-gray-100 text-gray-600' :
|
email.feedback === 'not_important'? 'bg-gray-100 text-gray-600' :
|
||||||
email.feedback === 'never_notify' ? 'bg-orange-50 text-orange-700' :
|
email.feedback === 'never_notify' ? 'bg-orange-50 text-orange-700' :
|
||||||
'bg-blue-50 text-blue-700'
|
'bg-blue-50 text-blue-700'
|
||||||
}`}>
|
}`}>
|
||||||
@@ -172,19 +220,14 @@ export default function MailHistoryPage() {
|
|||||||
) : (
|
) : (
|
||||||
<span className="text-gray-400 text-xs">—</span>
|
<span className="text-gray-400 text-xs">—</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
<button
|
||||||
<td className="px-4 py-3 text-center">
|
onClick={() => openFeedbackModal(email)}
|
||||||
{email.feedback_token && (
|
className="inline-flex items-center gap-1 text-gray-400 hover:text-blue-600 text-xs transition-colors"
|
||||||
<a
|
title="フィードバックを設定"
|
||||||
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" />
|
<MessageSquare className="h-3.5 w-3.5" />
|
||||||
回答
|
</button>
|
||||||
</a>
|
</div>
|
||||||
)}
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
@@ -196,6 +239,75 @@ export default function MailHistoryPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* フィードバックモーダル */}
|
||||||
|
{feedbackModal && (
|
||||||
|
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={closeFeedbackModal}>
|
||||||
|
<div className="bg-white rounded-xl shadow-xl p-6 w-80 mx-4" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-1">フィードバック</h3>
|
||||||
|
<p className="text-xs text-gray-500 mb-1 truncate">{feedbackModal.sender_email}</p>
|
||||||
|
<p className="text-xs text-gray-700 mb-4 truncate">{feedbackModal.subject}</p>
|
||||||
|
|
||||||
|
<div className="space-y-2 mb-3">
|
||||||
|
{FEEDBACK_OPTIONS.map(opt => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
onClick={() => setPendingFeedback(opt.value)}
|
||||||
|
className={`w-full text-left px-3 py-2 rounded-lg text-sm border transition-colors ${
|
||||||
|
pendingFeedback === opt.value
|
||||||
|
? opt.color + ' font-medium'
|
||||||
|
: 'border-gray-200 text-gray-600 hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{needsScope && (
|
||||||
|
<div className="mb-4 p-3 bg-gray-50 rounded-lg text-sm">
|
||||||
|
<p className="text-xs text-gray-500 mb-2">適用範囲:</p>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<label className="flex items-center gap-1.5 cursor-pointer text-gray-700">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
value="address"
|
||||||
|
checked={pendingScope === 'address'}
|
||||||
|
onChange={() => setPendingScope('address')}
|
||||||
|
/>
|
||||||
|
このアドレス
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-1.5 cursor-pointer text-gray-700">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
value="domain"
|
||||||
|
checked={pendingScope === 'domain'}
|
||||||
|
onChange={() => setPendingScope('domain')}
|
||||||
|
/>
|
||||||
|
このドメイン
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={closeFeedbackModal}
|
||||||
|
className="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 text-gray-700"
|
||||||
|
>
|
||||||
|
キャンセル
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={submitFeedback}
|
||||||
|
disabled={!pendingFeedback || submitting}
|
||||||
|
className="flex-1 px-3 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{submitting ? '送信中...' : '確定'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user