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,145 @@
'use client';
// File: components/review-team/ReviewTeamForm.tsx
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 { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { X } from 'lucide-react';
import { useState } from 'react';
const reviewTeamSchema = z.object({
name: z.string().min(1, 'Name is required').max(100),
description: z.string().max(255).optional(),
defaultForRfaTypes: z.array(z.string()).optional(),
});
type ReviewTeamFormValues = z.infer<typeof reviewTeamSchema>;
interface ReviewTeamFormProps {
projectPublicId: string;
defaultValues?: Partial<ReviewTeamFormValues>;
onSubmit: (values: ReviewTeamFormValues & { projectPublicId: string }) => void;
isLoading?: boolean;
}
const RFA_TYPE_OPTIONS = ['SDW', 'DDW', 'ADW', 'MS', 'MAT', 'BOQ'];
export function ReviewTeamForm({
projectPublicId,
defaultValues,
onSubmit,
isLoading,
}: ReviewTeamFormProps) {
const [typeInput, setTypeInput] = useState('');
const form = useForm<ReviewTeamFormValues>({
resolver: zodResolver(reviewTeamSchema),
defaultValues: {
name: defaultValues?.name ?? '',
description: defaultValues?.description ?? '',
defaultForRfaTypes: defaultValues?.defaultForRfaTypes ?? [],
},
});
const rfaTypes = form.watch('defaultForRfaTypes') ?? [];
const addRfaType = (type: string) => {
if (type && !rfaTypes.includes(type)) {
form.setValue('defaultForRfaTypes', [...rfaTypes, type]);
}
setTypeInput('');
};
const removeRfaType = (type: string) => {
form.setValue(
'defaultForRfaTypes',
rfaTypes.filter((t) => t !== type),
);
};
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit((values) =>
onSubmit({ ...values, projectPublicId }),
)}
className="space-y-4"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Team Name</FormLabel>
<FormControl>
<Input placeholder="e.g. Structural Review Team" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea placeholder="Optional description..." {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormItem>
<FormLabel>Default for RFA Types</FormLabel>
<div className="flex flex-wrap gap-2 mb-2">
{rfaTypes.map((type) => (
<Badge key={type} variant="secondary" className="gap-1">
{type}
<button
type="button"
onClick={() => removeRfaType(type)}
className="ml-1 hover:text-destructive"
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
<div className="flex gap-2">
{RFA_TYPE_OPTIONS.filter((t) => !rfaTypes.includes(t)).map((type) => (
<button
key={type}
type="button"
onClick={() => addRfaType(type)}
className="text-xs px-2 py-1 rounded border border-dashed hover:bg-accent"
>
+ {type}
</button>
))}
</div>
<input type="hidden" value={typeInput} onChange={(e) => setTypeInput(e.target.value)} />
</FormItem>
<Button type="submit" disabled={isLoading}>
{isLoading ? 'Saving...' : 'Save Team'}
</Button>
</form>
</Form>
);
}
@@ -0,0 +1,84 @@
'use client';
// File: components/review-team/ReviewTeamSelector.tsx
// Selector component สำหรับเลือก Review Team ตอน Submit RFA (T023)
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Badge } from '@/components/ui/badge';
import { Users } from 'lucide-react';
import { useReviewTeams } from '@/hooks/use-review-teams';
import { ReviewTeam } from '@/types/review-team';
interface ReviewTeamSelectorProps {
projectPublicId: string;
rfaTypeCode?: string;
value?: string;
onChange: (publicId: string | undefined) => void;
disabled?: boolean;
}
export function ReviewTeamSelector({
projectPublicId,
rfaTypeCode,
value,
onChange,
disabled,
}: ReviewTeamSelectorProps) {
const { data: teams = [], isLoading } = useReviewTeams({
projectPublicId,
isActive: true,
});
// กรอง teams ที่ match กับ rfaTypeCode (ถ้ากำหนด)
const filteredTeams = rfaTypeCode
? (teams as ReviewTeam[]).filter(
(t) => !t.defaultForRfaTypes?.length || t.defaultForRfaTypes.includes(rfaTypeCode),
)
: (teams as ReviewTeam[]);
return (
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm font-medium">
<Users className="h-4 w-4" />
<span>Review Team (Parallel Review)</span>
<Badge variant="outline" className="text-xs">Optional</Badge>
</div>
<Select
value={value ?? ''}
onValueChange={(v: string) => onChange(v || undefined)}
disabled={disabled || isLoading}
>
<SelectTrigger>
<SelectValue placeholder={isLoading ? 'Loading teams...' : 'Skip — no parallel review'} />
</SelectTrigger>
<SelectContent>
<SelectItem value="">Skip no parallel review</SelectItem>
{filteredTeams.map((team) => (
<SelectItem key={team.publicId} value={team.publicId}>
<div className="flex items-center gap-2">
<span>{team.name}</span>
{(team.members ?? []).length > 0 && (
<span className="text-xs text-muted-foreground">
({team.members?.length} members)
</span>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
{value && (
<p className="text-xs text-muted-foreground">
Parallel review tasks will be created for each discipline in the selected team.
</p>
)}
</div>
);
}
@@ -0,0 +1,184 @@
'use client';
// File: components/review-team/TeamMemberManager.tsx
// จัดการสมาชิกของ Review Team แยกตาม Discipline (FR-001)
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Trash2, UserPlus } from 'lucide-react';
import { useAddTeamMember, useRemoveTeamMember } from '@/hooks/use-review-teams';
import { ReviewTeamMemberRole } from '@/types/review-team';
interface Member {
publicId: string;
role: ReviewTeamMemberRole;
user?: { publicId: string; fullName?: string; email?: string };
discipline?: { publicId: string; disciplineCode: string; codeNameEn?: string };
}
interface User {
publicId: string;
fullName?: string;
email?: string;
}
interface Discipline {
publicId: string;
disciplineCode: string;
codeNameEn?: string;
}
interface TeamMemberManagerProps {
teamPublicId: string;
members: Member[];
availableUsers: User[];
availableDisciplines: Discipline[];
}
const ROLE_LABELS: Record<ReviewTeamMemberRole, string> = {
REVIEWER: 'Reviewer',
LEAD: 'Lead',
MANAGER: 'Manager',
};
const ROLE_BADGE_VARIANT: Record<ReviewTeamMemberRole, 'default' | 'secondary' | 'outline'> = {
LEAD: 'default',
MANAGER: 'secondary',
REVIEWER: 'outline',
};
export function TeamMemberManager({
teamPublicId,
members,
availableUsers,
availableDisciplines,
}: TeamMemberManagerProps) {
const [selectedUser, setSelectedUser] = useState('');
const [selectedDiscipline, setSelectedDiscipline] = useState('');
const [selectedRole, setSelectedRole] = useState<ReviewTeamMemberRole>('REVIEWER');
const addMember = useAddTeamMember();
const removeMember = useRemoveTeamMember();
const handleAdd = () => {
if (!selectedUser || !selectedDiscipline) return;
addMember.mutate(
{
teamPublicId,
data: {
userPublicId: selectedUser,
disciplinePublicId: selectedDiscipline,
role: selectedRole,
},
},
{
onSuccess: () => {
setSelectedUser('');
setSelectedDiscipline('');
setSelectedRole('REVIEWER');
},
},
);
};
return (
<div className="space-y-4">
{/* Member List */}
<div className="space-y-2">
{members.length === 0 && (
<p className="text-sm text-muted-foreground">No members assigned yet.</p>
)}
{members.map((member) => (
<div
key={member.publicId}
className="flex items-center justify-between p-3 rounded-lg border bg-card"
>
<div className="flex items-center gap-3">
<div>
<p className="text-sm font-medium">
{member.user?.fullName ?? member.user?.email ?? '—'}
</p>
<p className="text-xs text-muted-foreground">
{member.discipline?.disciplineCode} {member.discipline?.codeNameEn}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant={ROLE_BADGE_VARIANT[member.role]}>
{ROLE_LABELS[member.role]}
</Badge>
<Button
variant="ghost"
size="icon"
onClick={() =>
removeMember.mutate({ teamPublicId, memberPublicId: member.publicId })
}
disabled={removeMember.isPending}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</div>
))}
</div>
{/* Add Member Form */}
<div className="flex gap-2 pt-2 border-t">
<Select value={selectedUser} onValueChange={setSelectedUser}>
<SelectTrigger className="flex-1">
<SelectValue placeholder="Select user..." />
</SelectTrigger>
<SelectContent>
{availableUsers.map((u) => (
<SelectItem key={u.publicId} value={u.publicId}>
{u.fullName ?? u.email}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={selectedDiscipline} onValueChange={setSelectedDiscipline}>
<SelectTrigger className="w-36">
<SelectValue placeholder="Discipline..." />
</SelectTrigger>
<SelectContent>
{availableDisciplines.map((d) => (
<SelectItem key={d.publicId} value={d.publicId}>
{d.disciplineCode}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={selectedRole}
onValueChange={(v: string) => setSelectedRole(v as ReviewTeamMemberRole)}
>
<SelectTrigger className="w-28">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.keys(ROLE_LABELS) as ReviewTeamMemberRole[]).map((role) => (
<SelectItem key={role} value={role}>
{ROLE_LABELS[role]}
</SelectItem>
))}
</SelectContent>
</Select>
<Button onClick={handleAdd} disabled={!selectedUser || !selectedDiscipline || addMember.isPending}>
<UserPlus className="h-4 w-4 mr-1" />
Add
</Button>
</div>
</div>
);
}