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:
Nattanin
2026-05-12 16:17:27 +07:00
parent 3df8707b7f
commit ef20839f99
82 changed files with 7052 additions and 104 deletions
@@ -0,0 +1,82 @@
'use client';
// File: components/response-code/CodeImplications.tsx
// แสดงผลกระทบของ Response Code ที่เลือก (FR-007)
import { AlertTriangle, Clock, DollarSign, FileText, Leaf } from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { ResponseCode } from '@/types/review-team';
interface CodeImplicationsProps {
responseCode: ResponseCode;
}
const SEVERITY_VARIANTS = {
'3': { variant: 'destructive' as const, label: 'Critical — Document Rejected' },
'1C': { variant: 'default' as const, label: 'High — Contract Implications' },
'1D': { variant: 'default' as const, label: 'High — Alternative Approved' },
'2': { variant: 'default' as const, label: 'Moderate — Revision Required' },
};
export function CodeImplications({ responseCode }: CodeImplicationsProps) {
const impl = responseCode.implications;
const notifyRoles = responseCode.notifyRoles ?? [];
const hasImplications =
impl?.affectsSchedule ||
impl?.affectsCost ||
impl?.requiresContractReview ||
impl?.requiresEiaAmendment ||
notifyRoles.length > 0;
if (!hasImplications) return null;
const severityInfo = SEVERITY_VARIANTS[responseCode.code as keyof typeof SEVERITY_VARIANTS];
return (
<Alert variant={severityInfo?.variant ?? 'default'} className="mt-2">
<AlertTriangle className="h-4 w-4" />
<AlertTitle className="font-semibold">
{severityInfo?.label ?? 'Action Required'}
</AlertTitle>
<AlertDescription>
<div className="space-y-1 mt-1">
{impl?.affectsSchedule && (
<div className="flex items-center gap-2 text-sm">
<Clock className="h-3.5 w-3.5 flex-shrink-0" />
<span>May affect project schedule</span>
</div>
)}
{impl?.affectsCost && (
<div className="flex items-center gap-2 text-sm">
<DollarSign className="h-3.5 w-3.5 flex-shrink-0" />
<span>Cost impact QS assessment required</span>
</div>
)}
{impl?.requiresContractReview && (
<div className="flex items-center gap-2 text-sm">
<FileText className="h-3.5 w-3.5 flex-shrink-0" />
<span>Contract review required</span>
</div>
)}
{impl?.requiresEiaAmendment && (
<div className="flex items-center gap-2 text-sm">
<Leaf className="h-3.5 w-3.5 flex-shrink-0" />
<span>EIA amendment may be required</span>
</div>
)}
{notifyRoles.length > 0 && (
<div className="flex items-center gap-1 flex-wrap mt-1">
<span className="text-xs text-muted-foreground">Will notify:</span>
{notifyRoles.map((role) => (
<Badge key={role} variant="outline" className="text-xs">
{role.replace(/_/g, ' ')}
</Badge>
))}
</div>
)}
</div>
</AlertDescription>
</Alert>
);
}
@@ -0,0 +1,163 @@
'use client';
// File: components/response-code/MatrixEditor.tsx
// Visual editor สำหรับ Master Approval Matrix (T064, FR-022)
import React, { useState } from 'react';
import { Check, X, AlertTriangle, Lock } from 'lucide-react';
import { Switch } from '@/components/ui/switch';
import { Badge } from '@/components/ui/badge';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
interface MatrixRule {
publicId: string;
responseCode: {
publicId: string;
code: string;
descriptionEn: string;
category: string;
};
isEnabled: boolean;
requiresComments: boolean;
triggersNotification: boolean;
isOverridden: boolean;
isSystem?: boolean;
}
interface MatrixEditorProps {
documentTypeCode: string;
rules: MatrixRule[];
isProjectLevel?: boolean;
onToggleEnabled: (rulePublicId: string, enabled: boolean) => void;
onToggleRequiresComments: (rulePublicId: string, value: boolean) => void;
onToggleNotification: (rulePublicId: string, value: boolean) => void;
isLoading?: boolean;
}
const CATEGORY_ORDER = ['ENGINEERING', 'MATERIAL', 'CONTRACT', 'TESTING', 'ESG'];
export function MatrixEditor({
documentTypeCode,
rules,
isProjectLevel = false,
onToggleEnabled,
onToggleRequiresComments,
onToggleNotification,
isLoading,
}: MatrixEditorProps) {
const [filter, setFilter] = useState<string>('ALL');
const grouped = CATEGORY_ORDER.reduce<Record<string, MatrixRule[]>>((acc, cat) => {
const catRules = rules.filter(
(r) => r.responseCode.category === cat && (filter === 'ALL' || r.responseCode.category === filter),
);
if (catRules.length > 0) acc[cat] = catRules;
return acc;
}, {});
return (
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base">
Matrix: {documentTypeCode}
{isProjectLevel && (
<Badge variant="secondary" className="ml-2 text-xs">Project Override</Badge>
)}
</CardTitle>
<select
value={filter}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => setFilter(e.target.value)}
className="text-sm border rounded px-2 py-1"
>
<option value="ALL">All Categories</option>
{CATEGORY_ORDER.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
</div>
</CardHeader>
<CardContent className="pt-0">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-16">Code</TableHead>
<TableHead>Description</TableHead>
<TableHead className="w-24 text-center">Enabled</TableHead>
<TableHead className="w-28 text-center">Req. Comments</TableHead>
<TableHead className="w-28 text-center">Notify</TableHead>
<TableHead className="w-20 text-center">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{Object.entries(grouped).map(([cat, catRules]) => (
<React.Fragment key={cat}>
<TableRow className="bg-muted/30">
<TableCell colSpan={6} className="py-1 text-xs font-semibold text-muted-foreground uppercase tracking-wide">
{cat}
</TableCell>
</TableRow>
{catRules.map((rule) => (
<TableRow key={rule.publicId} className={!rule.isEnabled ? 'opacity-50' : ''}>
<TableCell>
<span className="font-mono text-sm font-bold">{rule.responseCode.code}</span>
</TableCell>
<TableCell className="text-sm">{rule.responseCode.descriptionEn}</TableCell>
<TableCell className="text-center">
{rule.isSystem ? (
<Lock className="h-4 w-4 mx-auto text-muted-foreground" />
) : (
<Switch
checked={rule.isEnabled}
onCheckedChange={(v: boolean) => onToggleEnabled(rule.publicId, v)}
disabled={isLoading}
/>
)}
</TableCell>
<TableCell className="text-center">
<Switch
checked={rule.requiresComments}
onCheckedChange={(v: boolean) => onToggleRequiresComments(rule.publicId, v)}
disabled={isLoading || !rule.isEnabled}
/>
</TableCell>
<TableCell className="text-center">
<Switch
checked={rule.triggersNotification}
onCheckedChange={(v: boolean) => onToggleNotification(rule.publicId, v)}
disabled={isLoading || !rule.isEnabled}
/>
</TableCell>
<TableCell className="text-center">
{rule.isOverridden ? (
<AlertTriangle className="h-4 w-4 mx-auto text-amber-500" />
) : rule.isEnabled ? (
<Check className="h-4 w-4 mx-auto text-green-500" />
) : (
<X className="h-4 w-4 mx-auto text-muted-foreground" />
)}
</TableCell>
</TableRow>
))}
</React.Fragment>
))}
{Object.keys(grouped).length === 0 && (
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground py-6">
No rules configured for this document type.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
);
}
@@ -0,0 +1,156 @@
'use client';
// File: components/response-code/ProjectOverrideManager.tsx
// จัดการ project-specific overrides ของ Master Approval Matrix (T065)
import React, { useState } from 'react';
import { Plus, Trash2, ArrowDownToLine } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
interface OverrideRule {
publicId: string;
responseCode: {
code: string;
descriptionEn: string;
category: string;
};
documentTypeCode: string;
isEnabled: boolean;
requiresComments: boolean;
triggersNotification: boolean;
}
interface ProjectOverrideManagerProps {
projectPublicId: string;
projectName: string;
overrides: OverrideRule[];
onDeleteOverride: (rulePublicId: string) => void;
onAddOverride: () => void;
isLoading?: boolean;
}
export function ProjectOverrideManager({
projectPublicId: _projectPublicId,
projectName,
overrides,
onDeleteOverride,
onAddOverride,
isLoading,
}: ProjectOverrideManagerProps) {
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
const grouped = overrides.reduce<Record<string, OverrideRule[]>>((acc, rule) => {
const key = rule.documentTypeCode;
if (!acc[key]) acc[key] = [];
acc[key].push(rule);
return acc;
}, {});
return (
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">{projectName}</CardTitle>
<p className="text-xs text-muted-foreground mt-0.5">
{overrides.length} project-specific override(s)
</p>
</div>
<Button size="sm" onClick={onAddOverride} disabled={isLoading}>
<Plus className="h-4 w-4 mr-1" />
Add Override
</Button>
</div>
</CardHeader>
<CardContent className="pt-0">
{overrides.length === 0 ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground py-4">
<ArrowDownToLine className="h-4 w-4" />
<span>Inheriting all rules from global defaults</span>
</div>
) : (
<div className="space-y-4">
{Object.entries(grouped).map(([docType, rules]) => (
<div key={docType}>
<p className="text-xs font-semibold text-muted-foreground uppercase mb-2">
{docType}
</p>
<div className="space-y-1.5">
{rules.map((rule) => (
<div
key={rule.publicId}
className="flex items-center justify-between py-1.5 px-2 rounded bg-muted/40"
>
<div className="flex items-center gap-2">
<span className="font-mono text-xs font-bold">
{rule.responseCode.code}
</span>
<span className="text-sm">{rule.responseCode.descriptionEn}</span>
<Badge variant={rule.isEnabled ? 'default' : 'outline'} className="text-xs">
{rule.isEnabled ? 'Enabled' : 'Disabled'}
</Badge>
{rule.requiresComments && (
<Badge variant="secondary" className="text-xs">Req. Comments</Badge>
)}
</div>
<AlertDialog
open={confirmDelete === rule.publicId}
onOpenChange={(open: boolean) => !open && setConfirmDelete(null)}
>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => setConfirmDelete(rule.publicId)}
>
<Trash2 className="h-3.5 w-3.5 text-destructive" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove Override?</AlertDialogTitle>
<AlertDialogDescription>
This will revert code <strong>{rule.responseCode.code}</strong> to the
global default settings for this project.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
onDeleteOverride(rule.publicId);
setConfirmDelete(null);
}}
>
Remove Override
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
))}
</div>
<Separator className="mt-3" />
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,102 @@
'use client';
// File: components/response-code/ResponseCodeSelector.tsx
// เลือก Response Code ตาม Category ของเอกสาร (FR-006)
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Badge } from '@/components/ui/badge';
import { useResponseCodesByDocType } from '@/hooks/use-response-codes';
import { ResponseCode } from '@/types/review-team';
interface ResponseCodeSelectorProps {
documentTypeId: number;
projectId?: number;
value?: string;
onChange: (publicId: string) => void;
disabled?: boolean;
placeholder?: string;
}
const SEVERITY_COLORS: Record<string, string> = {
'1A': 'bg-green-100 text-green-800',
'1B': 'bg-emerald-100 text-emerald-800',
'1C': 'bg-yellow-100 text-yellow-800',
'1D': 'bg-orange-100 text-orange-800',
'1E': 'bg-blue-100 text-blue-800',
'1F': 'bg-sky-100 text-sky-800',
'1G': 'bg-purple-100 text-purple-800',
'2': 'bg-amber-100 text-amber-800',
'3': 'bg-red-100 text-red-800',
'4': 'bg-gray-100 text-gray-600',
};
function CodeBadge({ code }: { code: string }) {
const colorClass = SEVERITY_COLORS[code] ?? 'bg-gray-100 text-gray-600';
return (
<span className={`inline-flex items-center rounded px-1.5 py-0.5 text-xs font-bold ${colorClass}`}>
{code}
</span>
);
}
export function ResponseCodeSelector({
documentTypeId,
projectId,
value,
onChange,
disabled,
placeholder = 'Select Response Code...',
}: ResponseCodeSelectorProps) {
const { data: codes = [], isLoading } = useResponseCodesByDocType(documentTypeId, projectId);
// กลุ่ม codes ตาม category
const grouped = (codes as ResponseCode[]).reduce<Record<string, ResponseCode[]>>(
(acc, code) => {
const cat = code.category;
if (!acc[cat]) acc[cat] = [];
acc[cat].push(code);
return acc;
},
{},
);
const categories = Object.keys(grouped);
return (
<Select value={value ?? ''} onValueChange={onChange} disabled={disabled || isLoading}>
<SelectTrigger>
<SelectValue placeholder={isLoading ? 'Loading codes...' : placeholder} />
</SelectTrigger>
<SelectContent>
{categories.length === 0 && !isLoading && (
<div className="p-3 text-sm text-muted-foreground">No codes available</div>
)}
{categories.map((cat) => (
<SelectGroup key={cat}>
<SelectLabel className="text-xs text-muted-foreground uppercase tracking-wide">
{cat}
</SelectLabel>
{grouped[cat].map((code) => (
<SelectItem key={code.publicId} value={code.publicId}>
<div className="flex items-center gap-2">
<CodeBadge code={code.code} />
<span className="text-sm">{code.descriptionEn}</span>
{(code.implications?.affectsCost || code.implications?.requiresContractReview) && (
<span className="text-xs text-orange-600 font-medium"> Action Required</span>
)}
</div>
</SelectItem>
))}
</SelectGroup>
))}
</SelectContent>
</Select>
);
}