feat(rfa): complete RFA Approval Refactor - all 9 phases (T001-T080)
Phase 1-2: Setup, SQL schema, enums, queue constants, base entities
Phase 3 (US1): ReviewTeam, ReviewTeamMember, ReviewTask, TaskCreationService
Phase 4 (US2): ResponseCode, ResponseCodeRule, ImplicationsService, NotificationTriggerService
Phase 5 (US3): Delegation entity, CircularDetectionService, DelegationService/Controller/Module
Phase 6 (US4): ReminderRule, SchedulerService, EscalationService, ReminderProcessor, ReminderModule
Phase 7 (US5): DistributionMatrix, DistributionRecipient, ApprovalListenerService (Strangler),
TransmittalCreatorService, DistributionProcessor, DistributionModule
Phase 8 (US6): MatrixManagementService, InheritanceService (global→project override)
Phase 9 (Polish): AggregateStatusService, ConsensusService, VetoOverrideService,
ParallelGatewayHandler, review-validators, optimistic locking in completeReview,
test stubs (unit/integration/e2e), jest.config.js updated for tests/ directory
Frontend: ReviewTaskInbox, ParallelProgress, VetoOverrideDialog, DelegationForm,
DelegatedBadge, MatrixEditor, ProjectOverrideManager, DistributionStatus,
ReminderHistory, ResponseCodeSelector, CodeImplications, CompleteReviewForm,
ReviewTeamForm, ReviewTeamSelector, TeamMemberManager
Closes #1
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
'use client';
|
||||
|
||||
// File: components/review-task/CompleteReviewForm.tsx
|
||||
// Form สำหรับบันทึกผล Review Task (FR-009) — Response Code + Comments
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ResponseCodeSelector } from '@/components/response-code/ResponseCodeSelector';
|
||||
import { CodeImplications } from '@/components/response-code/CodeImplications';
|
||||
import { useResponseCodes } from '@/hooks/use-response-codes';
|
||||
import { ResponseCode } from '@/types/review-team';
|
||||
import { useState } from 'react';
|
||||
|
||||
const completeReviewSchema = z.object({
|
||||
responseCodePublicId: z.string().uuid('Response Code is required'),
|
||||
comments: z.string().optional(),
|
||||
});
|
||||
|
||||
type CompleteReviewFormValues = z.infer<typeof completeReviewSchema>;
|
||||
|
||||
interface CompleteReviewFormProps {
|
||||
taskPublicId: string;
|
||||
documentTypeId: number;
|
||||
projectId?: number;
|
||||
onSubmit: (values: CompleteReviewFormValues) => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function CompleteReviewForm({
|
||||
taskPublicId: _taskPublicId,
|
||||
documentTypeId,
|
||||
projectId,
|
||||
onSubmit,
|
||||
isLoading,
|
||||
}: CompleteReviewFormProps) {
|
||||
const [selectedCode, setSelectedCode] = useState<ResponseCode | null>(null);
|
||||
const { data: allCodes = [] } = useResponseCodes();
|
||||
|
||||
const form = useForm<CompleteReviewFormValues>({
|
||||
resolver: zodResolver(completeReviewSchema),
|
||||
});
|
||||
|
||||
const handleCodeChange = (publicId: string) => {
|
||||
form.setValue('responseCodePublicId', publicId);
|
||||
const found = (allCodes as ResponseCode[]).find((c) => c.publicId === publicId) ?? null;
|
||||
setSelectedCode(found);
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="responseCodePublicId"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<FormLabel>Response Code</FormLabel>
|
||||
<FormControl>
|
||||
<ResponseCodeSelector
|
||||
documentTypeId={documentTypeId}
|
||||
projectId={projectId}
|
||||
value={form.watch('responseCodePublicId')}
|
||||
onChange={handleCodeChange}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{selectedCode && <CodeImplications responseCode={selectedCode} />}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="comments"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Comments
|
||||
{selectedCode?.code === '2' || selectedCode?.code === '3' ? (
|
||||
<span className="text-destructive ml-1">*</span>
|
||||
) : null}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Enter review comments..."
|
||||
className="min-h-[80px]"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? 'Submitting...' : 'Submit Review'}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
'use client';
|
||||
|
||||
// File: components/review-task/DelegatedBadge.tsx
|
||||
// แสดง indicator "Delegated from X" บน Review Task (T041)
|
||||
import { ArrowRightLeft } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from '@/components/ui/hover-card';
|
||||
|
||||
interface DelegatedBadgeProps {
|
||||
delegatedFromUser?: {
|
||||
publicId: string;
|
||||
fullName?: string;
|
||||
email?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function DelegatedBadge({ delegatedFromUser }: DelegatedBadgeProps) {
|
||||
if (!delegatedFromUser) return null;
|
||||
|
||||
const displayName = delegatedFromUser.fullName ?? delegatedFromUser.email ?? 'Unknown';
|
||||
|
||||
return (
|
||||
<HoverCard>
|
||||
<HoverCardTrigger asChild>
|
||||
<Badge variant="outline" className="gap-1 text-xs text-muted-foreground border-dashed cursor-pointer">
|
||||
<ArrowRightLeft className="h-3 w-3" />
|
||||
Delegated
|
||||
</Badge>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="w-auto p-2">
|
||||
<p className="text-sm">Delegated from: <strong>{displayName}</strong></p>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
'use client';
|
||||
|
||||
// File: components/review-task/ParallelProgress.tsx
|
||||
// Parallel review progress indicator แสดงทุก discipline tracks (T072)
|
||||
import React from 'react';
|
||||
import { CheckCircle2, Clock, AlertTriangle } from 'lucide-react';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
type TaskStatus = 'PENDING' | 'IN_PROGRESS' | 'COMPLETED' | 'DELEGATED' | 'EXPIRED' | 'CANCELLED';
|
||||
|
||||
interface DisciplineTrack {
|
||||
disciplineId: string;
|
||||
disciplineName: string;
|
||||
taskStatus: TaskStatus;
|
||||
responseCode?: string;
|
||||
dueDate?: string;
|
||||
assigneeName?: string;
|
||||
}
|
||||
|
||||
interface ParallelProgressProps {
|
||||
tracks: DisciplineTrack[];
|
||||
overallPct: number;
|
||||
isAllComplete: boolean;
|
||||
}
|
||||
|
||||
const TRACK_ICON: Record<TaskStatus, React.ElementType> = {
|
||||
PENDING: Clock,
|
||||
IN_PROGRESS: Clock,
|
||||
COMPLETED: CheckCircle2,
|
||||
DELEGATED: Clock,
|
||||
EXPIRED: AlertTriangle,
|
||||
CANCELLED: AlertTriangle,
|
||||
};
|
||||
|
||||
const TRACK_COLOR: Record<TaskStatus, string> = {
|
||||
PENDING: 'text-muted-foreground',
|
||||
IN_PROGRESS: 'text-blue-500',
|
||||
COMPLETED: 'text-green-500',
|
||||
DELEGATED: 'text-amber-500',
|
||||
EXPIRED: 'text-destructive',
|
||||
CANCELLED: 'text-muted-foreground',
|
||||
};
|
||||
|
||||
export function ParallelProgress({ tracks, overallPct, isAllComplete }: ParallelProgressProps) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Parallel Review Tracks</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold">{overallPct}%</span>
|
||||
{isAllComplete && (
|
||||
<Badge variant="default" className="text-xs">All Complete</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Progress value={overallPct} className="h-1.5" />
|
||||
|
||||
<div className="space-y-1.5">
|
||||
{tracks.map((track) => {
|
||||
const Icon = TRACK_ICON[track.taskStatus];
|
||||
const colorClass = TRACK_COLOR[track.taskStatus];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={track.disciplineId}
|
||||
className="flex items-center justify-between py-1.5 px-2 rounded bg-muted/30"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className={`h-4 w-4 ${colorClass} flex-shrink-0`} />
|
||||
<div>
|
||||
<p className="text-sm font-medium">{track.disciplineName}</p>
|
||||
{track.assigneeName && (
|
||||
<p className="text-xs text-muted-foreground">{track.assigneeName}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{track.responseCode && (
|
||||
<span className="font-mono font-bold text-foreground">{track.responseCode}</span>
|
||||
)}
|
||||
{track.dueDate && (
|
||||
<span>{new Date(track.dueDate).toLocaleDateString('th-TH', { day: '2-digit', month: 'short' })}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
'use client';
|
||||
|
||||
// File: components/review-task/ReviewTaskInbox.tsx
|
||||
// Review Task inbox พร้อม aggregate status indicator (T071)
|
||||
import React, { useState } from 'react';
|
||||
import { Clock, CheckCircle2, AlertTriangle, ArrowRightLeft, Users } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { DelegatedBadge } from '@/components/review-task/DelegatedBadge';
|
||||
|
||||
type ReviewTaskStatus = 'PENDING' | 'IN_PROGRESS' | 'COMPLETED' | 'DELEGATED' | 'EXPIRED' | 'CANCELLED';
|
||||
|
||||
interface ReviewTaskItem {
|
||||
publicId: string;
|
||||
status: ReviewTaskStatus;
|
||||
discipline?: { name: string };
|
||||
assignedToUser?: { publicId: string; fullName?: string; email?: string };
|
||||
delegatedFromUser?: { publicId: string; fullName?: string; email?: string };
|
||||
dueDate?: string;
|
||||
rfaNumber?: string;
|
||||
documentTitle?: string;
|
||||
}
|
||||
|
||||
interface AggregateStatus {
|
||||
total: number;
|
||||
completed: number;
|
||||
pending: number;
|
||||
completionPct: number;
|
||||
isAllComplete: boolean;
|
||||
hasExpired: boolean;
|
||||
}
|
||||
|
||||
interface ReviewTaskInboxProps {
|
||||
tasks: ReviewTaskItem[];
|
||||
aggregateStatus?: AggregateStatus;
|
||||
onStartTask: (taskPublicId: string) => void;
|
||||
onCompleteTask: (taskPublicId: string) => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
const STATUS_CONFIG: Record<ReviewTaskStatus, { label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' }> = {
|
||||
PENDING: { label: 'Pending', variant: 'outline' },
|
||||
IN_PROGRESS: { label: 'In Progress', variant: 'secondary' },
|
||||
COMPLETED: { label: 'Completed', variant: 'default' },
|
||||
DELEGATED: { label: 'Delegated', variant: 'secondary' },
|
||||
EXPIRED: { label: 'Expired', variant: 'destructive' },
|
||||
CANCELLED: { label: 'Cancelled', variant: 'outline' },
|
||||
};
|
||||
|
||||
export function ReviewTaskInbox({
|
||||
tasks,
|
||||
aggregateStatus,
|
||||
onStartTask,
|
||||
onCompleteTask,
|
||||
isLoading,
|
||||
}: ReviewTaskInboxProps) {
|
||||
const [filter, setFilter] = useState<ReviewTaskStatus | 'ALL'>('ALL');
|
||||
|
||||
const filtered = filter === 'ALL' ? tasks : tasks.filter((t) => t.status === filter);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{aggregateStatus && (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<Users className="h-4 w-4" />
|
||||
Parallel Review Progress
|
||||
</CardTitle>
|
||||
<span className="text-sm font-semibold">{aggregateStatus.completionPct}%</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0 space-y-2">
|
||||
<Progress value={aggregateStatus.completionPct} className="h-2" />
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span>{aggregateStatus.completed}/{aggregateStatus.total} tasks complete</span>
|
||||
{aggregateStatus.isAllComplete && (
|
||||
<Badge variant="default" className="text-xs gap-1">
|
||||
<CheckCircle2 className="h-3 w-3" /> All Complete
|
||||
</Badge>
|
||||
)}
|
||||
{aggregateStatus.hasExpired && (
|
||||
<Badge variant="destructive" className="text-xs gap-1">
|
||||
<AlertTriangle className="h-3 w-3" /> Has Expired
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{(['ALL', 'PENDING', 'IN_PROGRESS', 'COMPLETED'] as const).map((s) => (
|
||||
<Button
|
||||
key={s}
|
||||
variant={filter === s ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setFilter(s)}
|
||||
>
|
||||
{s === 'ALL' ? 'All' : STATUS_CONFIG[s]?.label ?? s}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{filtered.map((task) => {
|
||||
const config = STATUS_CONFIG[task.status];
|
||||
const isOverdue =
|
||||
task.dueDate && new Date(task.dueDate) < new Date() && task.status !== 'COMPLETED';
|
||||
|
||||
return (
|
||||
<Card key={task.publicId} className={isOverdue ? 'border-destructive/50' : ''}>
|
||||
<CardContent className="py-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-sm font-medium truncate">{task.rfaNumber ?? task.publicId}</span>
|
||||
<Badge variant={config.variant} className="text-xs">{config.label}</Badge>
|
||||
{task.delegatedFromUser && (
|
||||
<DelegatedBadge delegatedFromUser={task.delegatedFromUser} />
|
||||
)}
|
||||
{isOverdue && (
|
||||
<Badge variant="destructive" className="text-xs gap-1">
|
||||
<AlertTriangle className="h-3 w-3" /> Overdue
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{task.documentTitle && (
|
||||
<p className="text-xs text-muted-foreground truncate">{task.documentTitle}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-3 mt-1 text-xs text-muted-foreground">
|
||||
{task.discipline && <span>{task.discipline.name}</span>}
|
||||
{task.dueDate && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{new Date(task.dueDate).toLocaleDateString('th-TH')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{task.status === 'PENDING' && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onStartTask(task.publicId)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Start
|
||||
</Button>
|
||||
)}
|
||||
{task.status === 'IN_PROGRESS' && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => onCompleteTask(task.publicId)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Complete
|
||||
</Button>
|
||||
)}
|
||||
{task.status === 'COMPLETED' && (
|
||||
<CheckCircle2 className="h-5 w-5 text-green-500" />
|
||||
)}
|
||||
{task.status === 'DELEGATED' && (
|
||||
<ArrowRightLeft className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
|
||||
{filtered.length === 0 && (
|
||||
<div className="text-center text-muted-foreground py-8">
|
||||
No review tasks found.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
'use client';
|
||||
|
||||
// File: components/review-task/VetoOverrideDialog.tsx
|
||||
// PM Veto Override dialog — บังคับผ่าน RFA แม้มี Code 3 (T072.5)
|
||||
import React, { useState } from 'react';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
interface VetoOverrideDialogProps {
|
||||
rfaNumber: string;
|
||||
onConfirm: (reason: string) => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
const MIN_REASON_LENGTH = 10;
|
||||
|
||||
export function VetoOverrideDialog({ rfaNumber, onConfirm, isLoading }: VetoOverrideDialogProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [reason, setReason] = useState('');
|
||||
|
||||
const isValid = reason.trim().length >= MIN_REASON_LENGTH;
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!isValid) return;
|
||||
onConfirm(reason.trim());
|
||||
setOpen(false);
|
||||
setReason('');
|
||||
};
|
||||
|
||||
const handleOpenChange = (next: boolean) => {
|
||||
if (!next) setReason('');
|
||||
setOpen(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="destructive" size="sm">
|
||||
<AlertTriangle className="h-4 w-4 mr-1.5" />
|
||||
PM Override
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-5 w-5 text-destructive" />
|
||||
PM Veto Override
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Override the Code 3 rejection for <strong>{rfaNumber}</strong> and force-approve.
|
||||
This action will be permanently logged in the audit trail.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 py-2">
|
||||
<div>
|
||||
<label className="text-sm font-medium">Justification Reason *</label>
|
||||
<Textarea
|
||||
value={reason}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setReason(e.target.value)}
|
||||
placeholder="Provide a detailed reason for overriding the rejection (minimum 10 characters)..."
|
||||
className="mt-1.5 min-h-[100px]"
|
||||
/>
|
||||
<p className={`text-xs mt-1 ${isValid ? 'text-green-600' : 'text-muted-foreground'}`}>
|
||||
{reason.trim().length}/{MIN_REASON_LENGTH} minimum characters
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md bg-destructive/10 border border-destructive/20 p-3">
|
||||
<p className="text-xs text-destructive font-medium">
|
||||
⚠ This override is irreversible. All reviewers and stakeholders will be notified.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleConfirm}
|
||||
disabled={!isValid || isLoading}
|
||||
>
|
||||
{isLoading ? 'Processing...' : 'Confirm Override'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user