690525:2327 ADR-023-229 dynamic prompt #01
This commit is contained in:
@@ -0,0 +1,415 @@
|
||||
// File: frontend/components/admin/ai/OcrSandboxPromptManager.tsx
|
||||
// Change Log
|
||||
// - 2026-05-25: Created OcrSandboxPromptManager component for dynamic prompt editing, version control, and sandbox testing (ADR-029)
|
||||
// - 2026-05-25: Extracted inline strings to i18n keys via useTranslations() (Obs #1 fix)
|
||||
// - 2026-05-25: Refactored sandbox polling to useSandboxRun hook (Obs #2 fix)
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Brain,
|
||||
Save,
|
||||
AlertCircle,
|
||||
Upload,
|
||||
Play,
|
||||
FileJson,
|
||||
ScrollText,
|
||||
Loader2,
|
||||
StickyNote,
|
||||
} from 'lucide-react';
|
||||
import { useAiPrompts, useSandboxRun } from '@/hooks/use-ai-prompts';
|
||||
import { useTranslations } from '@/hooks/use-translations';
|
||||
import PromptVersionHistory from './PromptVersionHistory';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { AiPrompt } from '@/types/ai-prompts';
|
||||
|
||||
/**
|
||||
* Component หลักสำหรับจัดการ Prompt versions ของ OCR sandbox และ Migration
|
||||
* ประกอบไปด้วยตัวแก้ไข Prompt, รายการเวอร์ชัน, และส่วนสกัดทดสอบ (Sandbox run)
|
||||
*/
|
||||
export default function OcrSandboxPromptManager() {
|
||||
const t = useTranslations();
|
||||
const promptType = 'ocr_extraction';
|
||||
const {
|
||||
versionsQuery,
|
||||
createMutation,
|
||||
activateMutation,
|
||||
deleteMutation,
|
||||
updateNoteMutation,
|
||||
} = useAiPrompts(promptType);
|
||||
const versions = versionsQuery.data ?? [];
|
||||
const activePrompt = versions.find((v) => v.isActive);
|
||||
const [templateText, setTemplateText] = useState<string>('');
|
||||
const [ocrFile, setOcrFile] = useState<File | null>(null);
|
||||
const [manualNote, setManualNote] = useState<string>('');
|
||||
const [activeTab, setActiveTab] = useState<'editor' | 'sandbox'>('editor');
|
||||
const { state: sandboxState, jobId: sandboxJobId, submit: submitSandbox, reset: resetSandbox } =
|
||||
useSandboxRun(() => {
|
||||
// เมื่อ sandbox เสร็จสิ้น: รีเฟรชรายการเวอร์ชัน
|
||||
versionsQuery.refetch();
|
||||
toast.success(t('ai.prompt.sandboxSuccess'));
|
||||
});
|
||||
useEffect(() => {
|
||||
if (activePrompt && !templateText) {
|
||||
setTemplateText(activePrompt.template);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activePrompt]);
|
||||
const handleSaveVersion = async () => {
|
||||
if (!templateText.includes('{{ocr_text}}')) {
|
||||
toast.error(t('ai.prompt.placeholderError'));
|
||||
return;
|
||||
}
|
||||
if (templateText.length > 4000) {
|
||||
toast.error(t('ai.prompt.charLimitError'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await createMutation.mutateAsync(templateText);
|
||||
toast.success(t('ai.prompt.saveVersionSuccess'));
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { message?: string } } };
|
||||
toast.error(error.response?.data?.message || t('ai.prompt.saveVersionError'));
|
||||
}
|
||||
};
|
||||
const handleLoadTemplate = (version: AiPrompt) => {
|
||||
setTemplateText(version.template);
|
||||
setActiveTab('editor');
|
||||
toast.success(t('ai.prompt.loadSuccess', { version: String(version.versionNumber) }));
|
||||
};
|
||||
const handleActivateVersion = async (versionNumber: number) => {
|
||||
try {
|
||||
await activateMutation.mutateAsync(versionNumber);
|
||||
toast.success(t('ai.prompt.activateSuccess', { version: String(versionNumber) }));
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { message?: string } } };
|
||||
toast.error(error.response?.data?.message || t('ai.prompt.activateError'));
|
||||
}
|
||||
};
|
||||
const handleDeleteVersion = async (versionNumber: number) => {
|
||||
if (!confirm(t('ai.prompt.deleteConfirm', { version: String(versionNumber) }))) return;
|
||||
try {
|
||||
await deleteMutation.mutateAsync(versionNumber);
|
||||
toast.success(t('ai.prompt.deleteSuccess', { version: String(versionNumber) }));
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { message?: string } } };
|
||||
toast.error(error.response?.data?.message || t('ai.prompt.deleteError'));
|
||||
}
|
||||
};
|
||||
const handleSaveManualNote = async (versionNumber: number) => {
|
||||
try {
|
||||
await updateNoteMutation.mutateAsync({ versionNumber, note: manualNote });
|
||||
toast.success(t('ai.prompt.saveNoteSuccess'));
|
||||
setManualNote('');
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { message?: string } } };
|
||||
toast.error(error.response?.data?.message || t('ai.prompt.saveNoteError'));
|
||||
}
|
||||
};
|
||||
const handleSubmitOcr = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!activePrompt) {
|
||||
toast.error(t('ai.prompt.noActivePrompt'));
|
||||
return;
|
||||
}
|
||||
if (!ocrFile) {
|
||||
toast.error(t('ai.prompt.noFile'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
resetSandbox();
|
||||
await submitSandbox(ocrFile);
|
||||
toast.success(t('ai.prompt.uploadSuccess'));
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { message?: string } } };
|
||||
toast.error(error.response?.data?.message || t('ai.prompt.uploadError'));
|
||||
}
|
||||
};
|
||||
// แปล status key เป็นข้อความตาม locale ปัจจุบัน
|
||||
const statusLabel = sandboxState.statusText ? t(sandboxState.statusText) : '';
|
||||
return (
|
||||
<div className="grid gap-6 lg:grid-cols-12 items-start">
|
||||
<div className="lg:col-span-8 space-y-6">
|
||||
<div className="flex border-b border-border/20">
|
||||
<button
|
||||
onClick={() => setActiveTab('editor')}
|
||||
className={cn(
|
||||
'px-4 py-2 text-sm font-semibold border-b-2 -mb-[2px] transition-all',
|
||||
activeTab === 'editor'
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{t('ai.prompt.tabEditor')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('sandbox')}
|
||||
className={cn(
|
||||
'px-4 py-2 text-sm font-semibold border-b-2 -mb-[2px] transition-all',
|
||||
activeTab === 'sandbox'
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{t('ai.prompt.tabSandbox')}
|
||||
</button>
|
||||
</div>
|
||||
{activeTab === 'editor' ? (
|
||||
<Card className="border border-border/50 bg-background/50 backdrop-blur-md">
|
||||
<CardHeader className="pb-3 flex flex-row justify-between items-center">
|
||||
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||
<ScrollText className="h-4 w-4 text-primary" />
|
||||
{t('ai.prompt.cardTitle')}
|
||||
</CardTitle>
|
||||
{activePrompt && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{t('ai.prompt.activeLabel', { version: String(activePrompt.versionNumber) })}
|
||||
</Badge>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Textarea
|
||||
value={templateText}
|
||||
onChange={(e) => setTemplateText(e.target.value)}
|
||||
disabled={createMutation.isPending}
|
||||
rows={15}
|
||||
placeholder={t('ai.prompt.editorPlaceholder')}
|
||||
className="font-mono text-xs leading-relaxed resize-none border border-input bg-background/30"
|
||||
/>
|
||||
<div className="flex justify-between items-center text-[10px] text-muted-foreground">
|
||||
<span className={cn(templateText.includes('{{ocr_text}}') ? 'text-emerald-500' : 'text-amber-500')}>
|
||||
{templateText.includes('{{ocr_text}}')
|
||||
? t('ai.prompt.placeholderOk')
|
||||
: t('ai.prompt.placeholderMissing')}
|
||||
</span>
|
||||
<span className={cn(templateText.length > 4000 ? 'text-destructive font-bold' : '')}>
|
||||
{t('ai.prompt.charCount', { count: String(templateText.length) })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button
|
||||
onClick={handleSaveVersion}
|
||||
disabled={createMutation.isPending || templateText.length === 0}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{createMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-4 w-4" />
|
||||
)}
|
||||
{t('ai.prompt.saveVersion')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<Card className="border border-border/50 bg-background/50 backdrop-blur-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base font-semibold">
|
||||
<Upload className="h-4 w-4 text-primary" />
|
||||
{t('ai.prompt.sandboxCardTitle')}
|
||||
</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('ai.prompt.sandboxCardDesc')}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmitOcr} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center rounded-lg border border-dashed p-8 transition-all',
|
||||
ocrFile ? 'border-primary/50 bg-primary/5' : 'border-muted-foreground/20 hover:bg-muted/10'
|
||||
)}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
if (sandboxState.isRunning) return;
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (file?.name.toLowerCase().endsWith('.pdf')) {
|
||||
setOcrFile(file);
|
||||
} else {
|
||||
toast.error(t('ai.prompt.dropzonePdfOnly'));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Brain className="h-9 w-9 text-muted-foreground/50 mb-2 animate-bounce" />
|
||||
{ocrFile ? (
|
||||
<div className="text-center space-y-1">
|
||||
<p className="text-sm font-semibold">{ocrFile.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
({(ocrFile.size / 1024 / 1024).toFixed(2)} MB)
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={sandboxState.isRunning}
|
||||
onClick={() => setOcrFile(null)}
|
||||
className="mt-2 text-xs text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
{t('ai.prompt.removeFile')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center space-y-1">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('ai.prompt.dropzoneDrag')}
|
||||
</p>
|
||||
<input
|
||||
type="file"
|
||||
accept=".pdf"
|
||||
disabled={sandboxState.isRunning}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) setOcrFile(file);
|
||||
}}
|
||||
className="hidden"
|
||||
id="ocr-sandbox-file"
|
||||
/>
|
||||
<label
|
||||
htmlFor="ocr-sandbox-file"
|
||||
className="mt-2.5 inline-flex h-8 items-center justify-center rounded-md bg-secondary px-3.5 text-xs font-semibold cursor-pointer hover:bg-secondary/85 transition-colors"
|
||||
>
|
||||
{t('ai.prompt.dropzoneChoose')}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={sandboxState.isRunning || !ocrFile || !activePrompt}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{sandboxState.isRunning ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{t('ai.prompt.running')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="h-4 w-4" />
|
||||
{t('ai.prompt.runSandbox')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{sandboxState.isRunning && (
|
||||
<Card className="border border-amber-500/20 bg-amber-500/5">
|
||||
<CardContent className="pt-6 space-y-4">
|
||||
<div className="flex items-center justify-between text-xs font-medium">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin text-amber-500" />
|
||||
{statusLabel}
|
||||
</span>
|
||||
<span>{sandboxState.progress}%</span>
|
||||
</div>
|
||||
<Progress value={sandboxState.progress} className="h-1.5" />
|
||||
<div className="text-[10px] text-muted-foreground font-mono bg-background/50 p-2 rounded">
|
||||
Request ID: {sandboxJobId}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{sandboxState.result && sandboxState.result.status === 'completed' && (
|
||||
<div className="space-y-6">
|
||||
<Card className="border border-emerald-500/20 bg-background/50 backdrop-blur-md">
|
||||
<CardHeader className="border-b border-border/30 pb-3 flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-base text-emerald-600 dark:text-emerald-400 flex items-center gap-2">
|
||||
<FileJson className="h-4 w-4" />
|
||||
{t('ai.prompt.resultTitle')}
|
||||
</CardTitle>
|
||||
{activePrompt && (
|
||||
<Badge variant="outline" className="text-xs text-emerald-500 border-emerald-500/20 bg-emerald-500/5">
|
||||
{t('ai.prompt.resultVersionBadge', { version: String(activePrompt.versionNumber) })}
|
||||
</Badge>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="pt-4 space-y-4">
|
||||
<div className="relative rounded-md bg-muted p-4 font-mono text-xs overflow-auto max-h-[300px] border border-border/10">
|
||||
<pre className="text-emerald-600 dark:text-emerald-400 select-text leading-relaxed">
|
||||
{sandboxState.result.answer}
|
||||
</pre>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{activePrompt && (
|
||||
<Card className="border border-border/50 bg-background/50 backdrop-blur-md">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<StickyNote className="h-4 w-4 text-amber-500 animate-pulse" />
|
||||
{t('ai.prompt.noteCardTitle')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Textarea
|
||||
value={manualNote}
|
||||
onChange={(e) => setManualNote(e.target.value)}
|
||||
placeholder={t('ai.prompt.notePlaceholder')}
|
||||
rows={3}
|
||||
className="text-xs leading-relaxed resize-none bg-background/30"
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
disabled={updateNoteMutation.isPending || !manualNote.trim()}
|
||||
onClick={() => handleSaveManualNote(activePrompt.versionNumber)}
|
||||
className="flex items-center gap-2 text-xs"
|
||||
size="sm"
|
||||
>
|
||||
{updateNoteMutation.isPending ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t('ai.prompt.saveNote', { version: String(activePrompt.versionNumber) })}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{sandboxState.result && sandboxState.result.status === 'failed' && (
|
||||
<Card className="border border-destructive/20 bg-destructive/5">
|
||||
<CardHeader className="flex flex-row items-center gap-2 pb-2 text-destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<CardTitle className="text-sm font-medium">{t('ai.prompt.sandboxErrorTitle')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
{sandboxState.result.errorMessage || t('ai.prompt.sandboxErrorDefault')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="lg:col-span-4">
|
||||
<PromptVersionHistory
|
||||
versions={versions}
|
||||
isLoading={versionsQuery.isLoading}
|
||||
onLoadTemplate={handleLoadTemplate}
|
||||
onActivateVersion={handleActivateVersion}
|
||||
onDeleteVersion={handleDeleteVersion}
|
||||
isActivating={activateMutation.isPending}
|
||||
isDeleting={deleteMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
// File: frontend/components/admin/ai/PromptVersionHistory.tsx
|
||||
// Change Log
|
||||
// - 2026-05-25: Created PromptVersionHistory component (ADR-029)
|
||||
|
||||
import React from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { CheckCircle2, Trash2, BookOpen, Clock, StickyNote } from 'lucide-react';
|
||||
import { AiPrompt } from '@/types/ai-prompts';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface PromptVersionHistoryProps {
|
||||
versions: AiPrompt[];
|
||||
isLoading: boolean;
|
||||
onLoadTemplate: (version: AiPrompt) => void;
|
||||
onActivateVersion: (versionNumber: number) => void;
|
||||
onDeleteVersion: (versionNumber: number) => void;
|
||||
isActivating: boolean;
|
||||
isDeleting: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* แผงประวัติและเวอร์ชันของ AI Prompts ทางฝั่งขวามือ
|
||||
* แสดงรายการเวอร์ชันพร้อมปุ่มเรียกใช้ เปิดทำงาน หรือลบทิ้ง
|
||||
*/
|
||||
export default function PromptVersionHistory({
|
||||
versions,
|
||||
isLoading,
|
||||
onLoadTemplate,
|
||||
onActivateVersion,
|
||||
onDeleteVersion,
|
||||
isActivating,
|
||||
isDeleting,
|
||||
}: PromptVersionHistoryProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-[400px] items-center justify-center text-sm text-muted-foreground">
|
||||
<Clock className="mr-2 h-4 w-4 animate-spin text-primary" />
|
||||
กำลังโหลดประวัติเวอร์ชัน...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Card className="border border-border/50 bg-background/30 backdrop-blur-md transition-all duration-300 hover:shadow-md">
|
||||
<CardHeader className="pb-3 border-b border-border/10">
|
||||
<CardTitle className="flex items-center gap-2 text-sm font-semibold tracking-wide text-foreground">
|
||||
<BookOpen className="h-4 w-4 text-primary animate-pulse" />
|
||||
ประวัติเวอร์ชัน (Version History)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-4 px-3 sm:px-4 max-h-[600px] overflow-y-auto space-y-3">
|
||||
{versions.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-10 text-center text-xs text-muted-foreground italic">
|
||||
ไม่พบเวอร์ชันอื่นในระบบ
|
||||
</div>
|
||||
) : (
|
||||
versions.map((version) => (
|
||||
<div
|
||||
key={version.versionNumber}
|
||||
className={cn(
|
||||
'group relative rounded-lg border border-border/30 bg-background/50 p-3.5 transition-all duration-200 hover:border-primary/30 hover:bg-background/80',
|
||||
version.isActive && 'border-emerald-500/20 bg-emerald-500/[0.02] shadow-[inset_0_1px_3px_rgba(16,185,129,0.03)]'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-sm font-bold text-foreground">
|
||||
v{version.versionNumber}
|
||||
</span>
|
||||
{version.isActive ? (
|
||||
<Badge className="border-emerald-500/20 bg-emerald-500/10 text-emerald-500 hover:bg-emerald-500/20 text-[10px] py-0 px-1.5 flex items-center gap-1 select-none">
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
ใช้งานจริง (Active)
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-[10px] text-muted-foreground border-border/50 bg-background/40 select-none">
|
||||
ร่าง (Inactive)
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 text-[11px] text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
สร้าง: {new Date(version.createdAt).toLocaleString('th-TH')}
|
||||
</span>
|
||||
{version.lastTestedAt && (
|
||||
<span className="flex items-center gap-1 text-emerald-500/90">
|
||||
<CheckCircle2 className="h-3 w-3 text-emerald-500" />
|
||||
ทดสอบแล้ว: {new Date(version.lastTestedAt).toLocaleString('th-TH')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 opacity-90 sm:opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-[10px] text-muted-foreground hover:bg-secondary"
|
||||
onClick={() => onLoadTemplate(version)}
|
||||
>
|
||||
โหลด (Load)
|
||||
</Button>
|
||||
{!version.isActive && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={isActivating}
|
||||
className="h-7 text-[10px] text-emerald-500 hover:text-emerald-600 hover:bg-emerald-500/10"
|
||||
onClick={() => onActivateVersion(version.versionNumber)}
|
||||
>
|
||||
ใช้งาน (Activate)
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={isDeleting}
|
||||
className="h-7 w-7 text-destructive hover:bg-destructive/10"
|
||||
onClick={() => onDeleteVersion(version.versionNumber)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{version.manualNote && (
|
||||
<div className="mt-2.5 rounded bg-muted/30 p-2 border border-border/10 flex gap-1.5 items-start text-[11px] text-muted-foreground select-text">
|
||||
<StickyNote className="h-3.5 w-3.5 text-amber-500 shrink-0 mt-0.5" />
|
||||
<p className="leading-relaxed whitespace-pre-wrap">{version.manualNote}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user