feat(logistics): Email Templates tab (toggle/subject/preview/test) + logistics in global settings
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
222
src/components/admin/logistics/email-templates-tab.tsx
Normal file
222
src/components/admin/logistics/email-templates-tab.tsx
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useRef } from 'react'
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { Switch } from '@/components/ui/switch'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { Plane, Mail, Eye, Loader2 } from 'lucide-react'
|
||||||
|
import { EmailPreviewDialog } from '@/components/admin/round/email-preview-dialog'
|
||||||
|
|
||||||
|
export function EmailTemplatesTab({ programId }: { programId?: string }) {
|
||||||
|
const [previewType, setPreviewType] = useState<string | null>(null)
|
||||||
|
const [testingType, setTestingType] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const utils = trpc.useUtils()
|
||||||
|
|
||||||
|
const { data: allSettings, isLoading } = trpc.notification.getEmailSettings.useQuery()
|
||||||
|
|
||||||
|
const settings = (allSettings ?? []).filter((s) => s.category === 'logistics')
|
||||||
|
|
||||||
|
const updateMutation = trpc.notification.updateEmailSetting.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Setting updated')
|
||||||
|
void utils.notification.getEmailSettings.invalidate()
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(`Failed to update: ${error.message}`)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const testMutation = trpc.notification.sendTestEmail.useMutation({
|
||||||
|
onSuccess: (data) => {
|
||||||
|
if (data.success) {
|
||||||
|
toast.success(data.message, {
|
||||||
|
description: data.hasStyledTemplate ? 'Using styled template' : 'Using generic template',
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
toast.error('Failed to send test email', { description: data.message })
|
||||||
|
}
|
||||||
|
setTestingType(null)
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(`Failed to send: ${error.message}`)
|
||||||
|
setTestingType(null)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const preview = trpc.notification.previewEmailTemplate.useQuery(
|
||||||
|
{ notificationType: previewType! },
|
||||||
|
{ enabled: !!previewType },
|
||||||
|
)
|
||||||
|
|
||||||
|
const previewSetting = settings.find((s) => s.notificationType === previewType)
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{[...Array(4)].map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-16 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings.length === 0) {
|
||||||
|
return (
|
||||||
|
<p className="text-sm text-muted-foreground py-8 text-center">
|
||||||
|
No logistics email types found — run the notification settings seed.
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="flex items-center gap-3 text-base">
|
||||||
|
<Plane className="h-5 w-5 text-muted-foreground" />
|
||||||
|
Logistics Emails
|
||||||
|
<span className="ml-auto text-xs font-normal text-muted-foreground">
|
||||||
|
{settings.filter((s) => s.sendEmail).length}/{settings.length} enabled
|
||||||
|
</span>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{settings.map((setting) => (
|
||||||
|
<EmailTemplateRow
|
||||||
|
key={setting.id}
|
||||||
|
setting={setting}
|
||||||
|
isTesting={testingType === setting.notificationType}
|
||||||
|
isUpdating={updateMutation.isPending}
|
||||||
|
onTest={() => {
|
||||||
|
setTestingType(setting.notificationType)
|
||||||
|
testMutation.mutate({ notificationType: setting.notificationType })
|
||||||
|
}}
|
||||||
|
onPreview={() => setPreviewType(setting.notificationType)}
|
||||||
|
onToggle={(checked) =>
|
||||||
|
updateMutation.mutate({
|
||||||
|
notificationType: setting.notificationType,
|
||||||
|
sendEmail: checked,
|
||||||
|
emailSubject: setting.emailSubject ?? undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onSubjectBlur={(subject) => {
|
||||||
|
if (subject !== (setting.emailSubject ?? '')) {
|
||||||
|
updateMutation.mutate({
|
||||||
|
notificationType: setting.notificationType,
|
||||||
|
sendEmail: setting.sendEmail,
|
||||||
|
emailSubject: subject || undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<EmailPreviewDialog
|
||||||
|
open={!!previewType}
|
||||||
|
onOpenChange={(o) => { if (!o) setPreviewType(null) }}
|
||||||
|
title={previewSetting?.label ?? 'Email Preview'}
|
||||||
|
description={previewSetting?.description ?? ''}
|
||||||
|
recipientCount={0}
|
||||||
|
previewHtml={preview.data?.html}
|
||||||
|
isPreviewLoading={preview.isLoading}
|
||||||
|
onSend={() => {}}
|
||||||
|
isSending={false}
|
||||||
|
previewOnly
|
||||||
|
showCustomMessage={false}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type RowSetting = {
|
||||||
|
id: string
|
||||||
|
notificationType: string
|
||||||
|
category: string
|
||||||
|
label: string
|
||||||
|
description: string | null
|
||||||
|
sendEmail: boolean
|
||||||
|
emailSubject: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmailTemplateRow({
|
||||||
|
setting,
|
||||||
|
isTesting,
|
||||||
|
isUpdating,
|
||||||
|
onTest,
|
||||||
|
onPreview,
|
||||||
|
onToggle,
|
||||||
|
onSubjectBlur,
|
||||||
|
}: {
|
||||||
|
setting: RowSetting
|
||||||
|
isTesting: boolean
|
||||||
|
isUpdating: boolean
|
||||||
|
onTest: () => void
|
||||||
|
onPreview: () => void
|
||||||
|
onToggle: (checked: boolean) => void
|
||||||
|
onSubjectBlur: (value: string) => void
|
||||||
|
}) {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border p-3 space-y-2">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="space-y-0.5 flex-1 min-w-0">
|
||||||
|
<Label className="text-sm font-medium">{setting.label}</Label>
|
||||||
|
{setting.description && (
|
||||||
|
<p className="text-xs text-muted-foreground">{setting.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 px-2 text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={onPreview}
|
||||||
|
title="Preview email"
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
<span className="ml-1.5 text-xs">Preview</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 px-2 text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={onTest}
|
||||||
|
disabled={isTesting}
|
||||||
|
title="Send test email to yourself"
|
||||||
|
>
|
||||||
|
{isTesting ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Mail className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
<span className="ml-1.5 text-xs">Test</span>
|
||||||
|
</Button>
|
||||||
|
<Switch
|
||||||
|
checked={setting.sendEmail}
|
||||||
|
onCheckedChange={onToggle}
|
||||||
|
disabled={isUpdating}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label className="text-xs text-muted-foreground w-16 shrink-0">Subject</Label>
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
defaultValue={setting.emailSubject ?? ''}
|
||||||
|
placeholder="(default subject)"
|
||||||
|
onBlur={(e) => onSubjectBlur(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { Users, Scale, GraduationCap, Eye, Shield, Mail, Loader2 } from 'lucide-react'
|
import { Users, Scale, GraduationCap, Eye, Shield, Mail, Loader2, Plane } from 'lucide-react'
|
||||||
|
|
||||||
// Category icons and labels
|
// Category icons and labels
|
||||||
const CATEGORIES = {
|
const CATEGORIES = {
|
||||||
@@ -17,6 +17,7 @@ const CATEGORIES = {
|
|||||||
mentor: { label: 'Mentors', icon: GraduationCap },
|
mentor: { label: 'Mentors', icon: GraduationCap },
|
||||||
observer: { label: 'Observers', icon: Eye },
|
observer: { label: 'Observers', icon: Eye },
|
||||||
admin: { label: 'Administrators', icon: Shield },
|
admin: { label: 'Administrators', icon: Shield },
|
||||||
|
logistics: { label: 'Logistics', icon: Plane },
|
||||||
}
|
}
|
||||||
|
|
||||||
type NotificationSetting = {
|
type NotificationSetting = {
|
||||||
|
|||||||
Reference in New Issue
Block a user