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,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user