You - 10/31/2025, 3:05 AMlllllllllllllllll
You - 10/31/2025, 3:05 AMlllllllllllllllllll
You - 10/31/2025, 3:05 AMlllllllllll
You - 10/31/2025, 3:05 AMlllllllll
You - 10/31/2025, 3:05 AMllllllll
You - 10/31/2025, 3:05 AMllllllllllllllll
You - 10/31/2025, 3:05 AMllllllllllllllll
You - 10/31/2025, 3:05 AMlllllllll
You - 10/31/2025, 3:05 AMllllllll
You - 10/31/2025, 3:05 AMlllllll
You - 10/31/2025, 3:05 AMlllllllllllll
You - 10/31/2025, 3:05 AMlllllll
You - 10/31/2025, 3:05 AMlllllllll
You - 10/31/2025, 3:05 AMllllllll
You - 10/31/2025, 3:05 AMjijj
You - 10/31/2025, 3:06 AM/* eslint-disable @typescript-eslint/no-explicit-any */ "use client"; import Form from "next/form"; import { Field, FieldDescription, FieldGroup, FieldLabel, FieldSet, } from "@/components/ui/field"; import { Input } from "@/components/ui/input"; import { Checkbox } from "@/components/ui/checkbox"; import { Button } from "@/components/ui/button"; import { updateTodo } from "@/app/todos/actions/update-todo"; import { useActionState } from "react"; import { Spinner } from "@/components/ui/spinner"; import { useRouter } from "next/navigation"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { AlertCircle } from "lucide-react"; export default function EditTodoForm({ id, todo }: { id: string; todo: any }) { const updateTodoById = updateTodo.bind(null, id); const [state, formAction, pending] = useActionState(updateTodoById, { data: { completed: todo.completed, title: todo.title }, success: false, }); const router = useRouter(); return ( <Form action={formAction} className="w-full max-w-md"> <FieldSet> <FieldGroup> <Field> <FieldLabel htmlFor="title">Title</FieldLabel> <Input id="title" name="title" type="text" placeholder="Todo Title" defaultValue={state.data.title} errors={state.errors?.title} /> <FieldDescription> Enter a descriptive title for your todo. </FieldDescription> </Field> <Field orientation="horizontal"> <Checkbox id="completed" name="completed" defaultChecked={state.data.completed} /> <FieldLabel htmlFor="completed">Mark as Completed</FieldLabel> </Field> <Field orientation="horizontal"> <Button variant="outline" type="button" disabled={pending} onClick={() => router.back()} > Cancel </Button> <Button type="submit"> {pending ? ( <> <Spinner /> Saving </> ) : ( "Save" )} </Button> </Field> </FieldGroup> </FieldSet> {state.errors?.general && ( <Alert variant="destructive" className="mt-4"> <AlertCircle /> <AlertDescription> {state.errors.general.map((error) => ( <p key={error}>{error}</p> ))} </AlertDescription> </Alert> )} </Form> ); }
You - 10/31/2025, 3:06 AMimport CommonDialog, { type IconType } from "@/components/dialog"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { Form, FormControl, FormField, FormItem } from "@/components/ui/form"; import { Drawer, DrawerClose, DrawerContent, DrawerHeader, DrawerTitle, } from "@/components/ui/drawer"; import DocumentIcon from "@/icons/document-icon"; import DownloadIcon from "@/icons/download-icon"; import HourglassEagerIcon from "@/icons/hourglass-eager-icon"; import { cn } from "@/lib/utils"; import { useForm, useFormContext, useWatch, type UseFormReturn, } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import type { BaseSyntheticEvent } from "react"; import { lazy, Suspense, useEffect, useState } from "react"; import NumberOfShares from "./number-of-shares"; import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { useSet, useUpdateEffect } from "react-use"; import { ScrollArea } from "@/components/ui/scroll-area"; import { NumericFormat } from "react-number-format"; import type { BatchCandidatesResponse, BatchIssuesResponse, IssuesBatchResponse, IssuesVoteDTO, MeetingInfoDTO, } from "@/types/information.type"; import { BATCH_TYPES, INFO_TYPES, VOTE_OPTIONS, type BatchType, type VoteOption, type VoteStatus, } from "@/constants/params"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { candidatesByBatchIdQueryOptions, informationQueryOptions, issuesByBatchIdQueryOptions, } from "@/features/information/information.api"; import { getLocalizedProperties, isHttpStatusSuccess } from "@/lib/helper"; import { useTranslation } from "react-i18next"; import type { Language } from "@/constants/language"; import { format } from "date-fns"; import { TIME_FORMAT } from "@/constants/format"; import type { ApiResponse } from "@/types/api.type"; import { Skeleton } from "@/components/ui/skeleton"; import { LoadingOverlay } from "@/components/ui/loading-overlay"; import { useMediaQuery } from "react-responsive"; import CancelIcon from "@/icons/cancel-icon"; import NoContent from "@/components/no-content"; import ErrorContent from "@/components/error-content"; import { downloadElectionPdf, downloadVotingPdf, voteCandidatesOptions, voteIssuesOptions, } from "@/features/meeting/meeting.api"; import { useError } from "@/contexts/error-provider"; import { createErrorWithFallback } from "@/lib/error-utils"; import type { RefetchProps } from "../$meetId/-components/batch-list"; import useAuthApi from "@/features/auth/auth.api"; import { Loader } from "lucide-react"; const LazyMeetingDocumentDialog = lazy( () => import("@/components/meeting-document-dialog") ); const LazyOtpVerificationDialog = lazy(() => import("@/components/otp-dialog")); interface IProps { open: boolean; onOpenChange: (open: boolean) => void; batch: IssuesBatchResponse; onShowResultDialog: (dialog: { open: boolean; content: string }) => void; refetchBatches: RefetchProps; } type DialogType = { open: boolean; isSuccess: boolean; message: React.ReactNode; type: IconType; }; export function getBadgeColorByStatus(status: VoteStatus) { switch (status) { case "CHOBOPHIEU": return "bg-primary-03 text-white"; case "DABOPHIEU": return "bg-primary text-white"; default: return "bg-background-07 text-foreground-09 [&_svg_path]:!fill-[#c5c5c5] [&_svg_path]:!stroke-[#c5c5c5]"; } } // Function to create election schema with dynamic total limit const createElectionSchema = (totalLimit: number) => z .object({ candidates: z.record( z.string(), z.number().min(0, "voteElection.election.error.minVote") ), }) .refine( data => { const total = Object.values(data.candidates).reduce( (sum, votes) => sum + votes, 0 ); return total <= totalLimit; }, { message: "voteElection.election.error.maxVote", path: ["candidates"], } ); type ElectionFormValues = z.infer<ReturnType<typeof createElectionSchema>>; // Form schema for voting const votingSchema = z.object({ batchVote: z .enum([ VOTE_OPTIONS.AGREE, VOTE_OPTIONS.DISAGREE, VOTE_OPTIONS.NOIDEA, VOTE_OPTIONS.NONE, VOTE_OPTIONS.INVALID, ]) .optional(), individualVotes: z .record( z.string(), z.enum([ VOTE_OPTIONS.AGREE, VOTE_OPTIONS.DISAGREE, VOTE_OPTIONS.NOIDEA, VOTE_OPTIONS.NONE, VOTE_OPTIONS.INVALID, ]) ) .refine( record => { const selectedValues = Object.values(record); // Return expected result return selectedValues.every(value => value !== VOTE_OPTIONS.NONE); }, { error: "voteElection.vote.error" } ), }); type VotingFormValues = z.infer<typeof votingSchema>; const VotingElectionDialog = ({ open, onOpenChange, batch, onShowResultDialog, refetchBatches, }: IProps) => { const isElection = batch.batchType !== BATCH_TYPES.BIEUQUYET; const isMobile = useMediaQuery({ maxWidth: 768 }); // Use separate queries for different batch types - only run when dialog is actually open const issuesOptions = issuesByBatchIdQueryOptions(batch.batchId); const issuesQuery = useQuery({ ...issuesOptions, enabled: !isElection && open, queryKey: [...issuesOptions.queryKey, String(open)], // Refetch when dialog opens refetchOnMount: false, }); const candidatesOptions = candidatesByBatchIdQueryOptions(batch.batchId); const candidatesQuery = useQuery({ ...candidatesOptions, enabled: isElection && open, queryKey: [...candidatesOptions.queryKey, String(open)], // Refetch when dialog opens refetchOnMount: false, }); const { i18n } = useTranslation(); const language = i18n.language as Language; const data = isElection ? candidatesQuery.data : issuesQuery.data; const isLoading = isElection ? candidatesQuery.isLoading : issuesQuery.isLoading; const { batchName } = getLocalizedProperties(batch, ["batchName"], language); const contentComponent = !isElection ? ( <VotingContent onOpenChange={onOpenChange} isLoading={isLoading} data={data as ApiResponse<BatchIssuesResponse | null>} onShowResultDialog={onShowResultDialog} invalidateBatchesQuery={refetchBatches} /> ) : ( <ElectionContent onOpenChange={onOpenChange} data={data as ApiResponse<BatchCandidatesResponse | null>} isLoading={isLoading} onShowResultDialog={onShowResultDialog} invalidateBatchesQuery={refetchBatches} /> ); const headerComponent = <VotingElectionHeader batch={batch} />; if (isMobile) { return ( <Drawer open={open} onOpenChange={onOpenChange}> <DrawerContent className="max-h-[90vh]!"> <DrawerHeader className="border-b-2 border-background-02 gap-2"> <div className="flex items-center justify-between"> <DrawerTitle className="text-left text-foreground-12 font-bold responsive-text-3xl"> {batchName} </DrawerTitle> <DrawerClose asChild> <Button variant="ghost" className="size-6 p-0!"> <CancelIcon className="size-6" /> <span className="sr-only">Close</span> </Button> </DrawerClose> </div> {headerComponent} </DrawerHeader> <div className="flex-1 overflow-auto">{contentComponent}</div> </DrawerContent> </Drawer> ); } return ( <CommonDialog noTrans title={batchName} content={contentComponent} open={open} className={cn( "max-w-[360px] xs:max-w-[600px] sm:max-w-[800px] gap-0", "p-0! [&>[data-slot='dialog-header']]:p-4 [&>[data-slot='dialog-header']]:border-b-2 [&>[data-slot='dialog-header']]:border-background-02 [&>[data-slot='dialog-close']]:top-4 [&>[data-slot='dialog-close']]:right-4" )} showCloseBtn={false} onOpenChange={onOpenChange} header={headerComponent} /> ); }; const VotingElectionHeader = ({ batch }: { batch: IssuesBatchResponse }) => { const { t } = useTranslation(["overview"]); return ( <div className="flex items-stretch justify-between"> <Badge variant="outlineDestructive" className="h-[unset]"> <HourglassEagerIcon className="!size-4" /> {t("voteElection.endTime", { endTime: batch.endTime && format(batch.endTime, TIME_FORMAT), })} </Badge> <Button isSmall variant="secondary" className={cn( "max-h-8 p-1.5 responsive-text-sm", getBadgeColorByStatus(batch.votingStatus) )} > {batch.votingStatus == "CHOBOPHIEU" ? t("voteElection.status.waiting") : batch.votingStatus == "DABOPHIEU" ? t("voteElection.status.voted") : t("voteElection.status.closed")} </Button> </div> ); }; const VotingContent = ({ onOpenChange, isLoading, data, onShowResultDialog, invalidateBatchesQuery, }: { onOpenChange: (open: boolean) => void; isLoading: boolean; data: ApiResponse<BatchIssuesResponse | null>; onShowResultDialog: (open: { open: boolean; content: string }) => void; invalidateBatchesQuery: RefetchProps; }) => { const { sendOtpVerify } = useAuthApi(); const { showError } = useError(); const { t, i18n } = useTranslation(["overview"]); const [showOtpDialog, setShowOtpDialog] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false); const language = i18n.language as Language; const form = useForm<VotingFormValues>({ resolver: zodResolver(votingSchema), defaultValues: { batchVote: VOTE_OPTIONS.NONE, individualVotes: {}, }, }); const isVerifyOtp = data?.data?.isRequestOtp; const isDisabled = !data?.data?.isAllowedToVote; const handleOpenDialog = () => { setShowOtpDialog(true); setIsDialogOpen(true); }; const handleCloseDialog = (open: boolean) => { setIsDialogOpen(open); }; // Side effect to initialize form values when data changes useEffect(() => { if (data?.data?.votingIssues) { const initialVotes = data.data.votingIssues.reduce< Record<string, VoteOption> >((acc, cur) => { acc[cur.issueId.toString()] = cur.eVote; return acc; }, {}); // Determine initial batch vote const votes = Object.values(initialVotes); const initialBatchVote = votes.length > 0 && votes.every(vote => vote === votes[0] && vote !== VOTE_OPTIONS.NONE) ? votes[0] : VOTE_OPTIONS.NONE; // Update form values form.setValue("individualVotes", initialVotes); form.setValue("batchVote", initialBatchVote); } }, [data?.data?.votingIssues, form]); const addVoteIssuesMutation = useMutation(voteIssuesOptions()); const [dialog, setDialog] = useState<DialogType>({ open: false, isSuccess: true, message: "", type: "none", }); const handleSubmit = async (params: VotingFormValues) => { // Handle voting form submission console.log("Voting data:", params); if (isVerifyOtp) { sendOtpVerify.mutate(undefined, { onSuccess: function ({ statusCode, ticketId, errorInfo }) { if (isHttpStatusSuccess(statusCode)) { handleOpenDialog(); } else { showError( createErrorWithFallback( { errorInfo, statusCode, }, { details: ticketId || undefined, showErrorCode: true } ) ); } }, }); return; } mutationForm(); }; const onSubmit = form.handleSubmit(handleSubmit); const mutationForm = async (otp?: string) => { if (!data.data) return; const { errorInfo, statusCode, ticketId } = await addVoteIssuesMutation.mutateAsync({ batchId: data.data.batchId, shareQtyConfirm: data.data.shareQtyConfirm, issues: Object.entries(form.getValues().individualVotes).map( ([issueId, vote]) => ({ issueId: Number(issueId), eVote: vote, }) ), otp, }); if (!errorInfo && isHttpStatusSuccess(statusCode)) { await invalidateBatchesQuery(); onShowResultDialog({ open: true, content: t("voteElection.vote.success"), }); handleCloseDialog(false); onOpenChange(false); } else { showError( createErrorWithFallback( { errorInfo, statusCode, }, { details: ticketId || undefined, showErrorCode: true } ) ); } }; // Watch for batchVote changes and update all individual votes const batchVote = useWatch({ control: form.control, name: "batchVote" }); useEffect(() => { // Only update individual votes when batchVote has a valid value (not empty string) if (batchVote !== VOTE_OPTIONS.NONE && batchVote !== undefined) { const newIndividualVotes = data.data?.votingIssues?.reduce( (acc, item) => { acc[item.issueId.toString()] = batchVote; return acc; }, {} as Record<string, VoteOption> ); if (newIndividualVotes) { form.setValue("individualVotes", newIndividualVotes); } } }, [batchVote, form]); // Handle form errors useEffect(() => { if (Object.keys(form.formState.errors).length > 0) { // Handle errors here const errors = Object.values(form.formState.errors); const message = errors.map((err, i) => { const code = err.message?.toString() || err.root?.message; return ( <p key={i}> {errors.length > 1 && "• "} {t(code)} </p> ); }); setDialog({ open: true, message, isSuccess: false, type: "error" }); } }, [form.formState.errors]); return ( <Form {...form}> <div className="px-4 py-3.5"> <div className="flex flex-col gap-3"> {/* Bỏ phiếu hàng loạt */} <div className="grid grid-cols-1 gap-y-2 xs:grid-cols-3 sm:grid-cols-2 px-2.5"> <h2 className="responsive-text text-primary font-semibold text-left"> {t("voteElection.vote.title")} </h2> <FormField disabled={isDisabled} control={form.control} name="batchVote" render={({ field }) => ( <FormItem className="xs:col-span-2 sm:col-span-1"> <FormControl> <RadioGroup value={field.value} onValueChange={field.onChange} className="grid grid-cols-3 gap-5 justify-items-start xs:justify-items-end" > <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.AGREE} id="batch-approve" /> <Label htmlFor="batch-approve" className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.agree")} </Label> </div> <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.DISAGREE} id="batch-disapprove" /> <Label htmlFor="batch-disapprove" className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.disagree")} </Label> </div> <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.NOIDEA} id="batch-abstain" /> <Label htmlFor="batch-abstain" className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.noOption")} </Label> </div> </RadioGroup> </FormControl> </FormItem> )} /> </div> {/* Bỏ phiếu từng biểu quyết */} {!isLoading ? ( data?.data ? ( <ScrollArea className="rounded-default"> {data.data.votingIssues ? ( data.data.votingIssues?.map((item, index) => ( <VotingItem key={item.issueId} item={item} isDisabled={isDisabled} length={data.data?.votingIssues?.length || 0} index={index} form={form} /> )) ) : ( <NoContent /> )} </ScrollArea> ) : ( <ErrorContent errorInfo={data?.errorInfo} className="m-4" /> ) ) : ( <div className="relative min-h-[150px]"> <Skeleton className="h-full rounded-default" /> <LoadingOverlay visible={isLoading} /> </div> )} </div> </div> <VotingElectionFooter onSubmit={onSubmit} isDisabled={isDisabled || isDialogOpen} batchId={data?.data?.batchId} batchName={ language === "vi" ? data?.data?.batchName : data?.data?.batchNameEn } batchType={data?.data?.batchType} /> <CommonDialog className="md:max-w-sm!" open={dialog.open} onOpenChange={(open: boolean) => { if (!open && !dialog.isSuccess) form.clearErrors(); if (!open && dialog.isSuccess && dialog.type !== "none") onOpenChange(false); setDialog({ ...dialog, open }); }} content={dialog.message} iconType={dialog.isSuccess ? "success" : "error"} /> {showOtpDialog && ( <Suspense fallback={<LoadingOverlay visible={true} />}> <LazyOtpVerificationDialog open={isDialogOpen} onOpenChange={handleCloseDialog} onConfirm={mutationForm} /> </Suspense> )} </Form> ); }; const ElectionContent = ({ onOpenChange, data, isLoading, onShowResultDialog, invalidateBatchesQuery, }: { onOpenChange: (open: boolean) => void; data: ApiResponse<BatchCandidatesResponse | null>; isLoading: boolean; onShowResultDialog: (open: { open: boolean; content: string }) => void; invalidateBatchesQuery: RefetchProps; }) => { const { sendOtpVerify } = useAuthApi(); const { showError } = useError(); const { t, i18n } = useTranslation(["overview"]); const [showOtpDialog, setShowOtpDialog] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false); const language = i18n.language as Language; const isVerifyOtp = data?.data?.isRequestOtp; // Safe access to data with loading check const totalLimit = (!isLoading && data?.data?.candidateShareholder?.totalVotes) || 0; const isDisabled = !data?.data?.isAllowedToVote; // Create dynamic schema with the total limit from API const dynamicElectionSchema = createElectionSchema(totalLimit); const form = useForm<ElectionFormValues>({ resolver: zodResolver(dynamicElectionSchema), defaultValues: { candidates: {}, }, }); const handleOpenDialog = () => { setShowOtpDialog(true); setIsDialogOpen(true); }; const handleCloseDialog = (open: boolean) => { setIsDialogOpen(open); }; // Side effect to initialize form values when data changes useEffect(() => { if (!isLoading && data?.data?.votingCandidates) { const initialValues: Record<string, number> = {}; data.data.votingCandidates.forEach(candidate => { initialValues[candidate.candidateId.toString()] = candidate.ballotCounter || 0; }); form.setValue("candidates", initialValues); } }, [data?.data?.votingCandidates, isLoading]); const addVoteCandidatesMutation = useMutation(voteCandidatesOptions()); const [dialog, setDialog] = useState<DialogType>({ open: false, isSuccess: true, message: "", type: "none", }); const isMobileScreen = useMediaQuery({ maxWidth: 480 }); // Watch candidates form values and calculate total voted dynamically using useWatch (form.watch doesn't work) const watchedCandidates = useWatch({ control: form.control, name: "candidates", }) as Record<string, number> | undefined; const totalVoted = Object.values(watchedCandidates || {}).reduce( (sum, v) => sum + (typeof v === "number" ? v : Number(v || 0)), 0 ); const handleSubmit = async (data: ElectionFormValues) => { // Handle election form submission console.log("Election data:", data); if (totalVoted <= 0) { setDialog({ message: t("voteElection.election.error.minVote"), open: true, type: "error", isSuccess: false, }); return; } if (totalVoted < totalLimit) { setDialog({ message: t("voteElection.election.error.remainVote", { remaining: totalLimit - totalVoted, }), open: true, type: "none", isSuccess: true, }); return; } handleVerifyOtpBeforeSubmit(); }; const handleVerifyOtpBeforeSubmit = () => { if (isVerifyOtp) { sendOtpVerify.mutate(undefined, { onSuccess: function ({ statusCode, ticketId, errorInfo }) { if (isHttpStatusSuccess(statusCode)) { handleOpenDialog(); } else { showError( createErrorWithFallback( { errorInfo, statusCode, }, { details: ticketId || undefined, showErrorCode: true } ) ); } }, }); return; } mutationForm(); }; const onSubmit = form.handleSubmit(handleSubmit); const mutationForm = async (otp?: string) => { if (!data.data) return; const formData = form.getValues(); const { errorInfo, statusCode, ticketId } = await addVoteCandidatesMutation.mutateAsync({ batchId: data.data.batchId, batchType: data.data.batchType, candidates: Object.entries(formData.candidates).map( ([candidateId, ballotCounter]) => ({ candidateId: Number(candidateId), ballotCounter, }) ), otp, }); if (!errorInfo && isHttpStatusSuccess(statusCode)) { await invalidateBatchesQuery(); onShowResultDialog({ open: true, content: t("voteElection.election.success"), }); handleCloseDialog(false); onOpenChange(false); } else { showError( createErrorWithFallback( { errorInfo, statusCode, }, { details: ticketId || undefined, showErrorCode: true } ) ); } }; const candidates = (!isLoading && data?.data?.votingCandidates) || []; const [setCandidate, { add, has, remove, reset }] = useSet(new Set<number>()); // Initialize checkboxes for candidates with existing votes (ballotCounter > 0) useUpdateEffect(() => { if (!isLoading && candidates.length > 0) { // Clear existing selections first reset(); // Calculate candidates with votes > 0 const candidatesWithVotes = candidates.filter( candidate => candidate.ballotCounter > 0 ); if (candidatesWithVotes.length > 0) { // Calculate average votes per candidate with votes const avgVotesPerCandidate = Math.floor( totalLimit / candidatesWithVotes.length ); // Only check candidates whose ballotCounter equals the average candidates.forEach(candidate => { if (candidate.ballotCounter === avgVotesPerCandidate) { add(candidate.candidateId); } }); } // Initialize form values with existing ballotCounter values (preserve actual data) const formValues: Record<string, number> = {}; candidates.forEach(candidate => { formValues[candidate.candidateId.toString()] = candidate.ballotCounter || 0; }); form.setValue("candidates", formValues); } }, [candidates, isLoading, totalLimit]); // Handle manual checkbox changes by user interaction const handleCheckboxChange = (candidateId: number, checked: boolean) => { // compute previous size before mutating the set to avoid off-by-one const prevSize = setCandidate.size; if (checked) { add(candidateId); } else { remove(candidateId); } // Calculate new selection set size after the change const newSelectionSize = checked ? prevSize + 1 : Math.max(0, prevSize - 1); if (newSelectionSize > 0) { const avgVotes = Math.floor(totalLimit / newSelectionSize); const formValues: Record<string, number> = {}; candidates.forEach(candidate => { const willBeSelected = candidate.candidateId === candidateId ? checked : has(candidate.candidateId); formValues[candidate.candidateId.toString()] = willBeSelected ? avgVotes : 0; }); form.setValue("candidates", formValues); } else { // Reset all values to 0 when no candidates are selected const formValues: Record<string, number> = {}; candidates.forEach(candidate => { formValues[candidate.candidateId.toString()] = 0; }); form.setValue("candidates", formValues); } }; return ( <Form {...form}> {!isLoading ? ( data?.data ? ( <div className="flex flex-col gap-3.5 px-4 pt-2.5 pb-3"> <div className="flex flex-col gap-2"> <h2 className="responsive-text text-primary font-semibold text-left"> {t("voteElection.election.numElectionInfo")} </h2> <div className="flex max-sm:overflow-x-auto max-sm:overflow-y-hidden gap-3 pb-1"> <div className="max-sm:flex-shrink-0 max-w-40 xs:max-w-52 sm:max-w-64 lg:max-w-none"> <NumberOfShares title={t("shares.shareQtyConfirm")} count={ data?.data.candidateShareholder?.shareQtyConfirm || 0 } /> </div> <div className="max-sm:flex-shrink-0 max-w-40 xs:max-w-52 sm:max-w-64 lg:max-w-none"> <NumberOfShares title={t("shares.candidateCounter")} count={ data?.data.candidateShareholder?.candidateCounter || 0 } /> </div> <div className="max-sm:flex-shrink-0 max-w-40 xs:max-w-52 sm:max-w-64 lg:max-w-none"> <NumberOfShares title={t("shares.totalVotes")} count={data?.data.candidateShareholder?.totalVotes || 0} /> </div> </div> </div> <div className="flex flex-col gap-2 mb-4 rounded-lg"> <h2 className="responsive-text text-primary font-semibold text-left"> {t("voteElection.election.infoCandidate")} </h2> <Table className="text-left"> <TableHeader> <TableRow> <TableHead> {t("voteElection.election.table.candidateName", { tag: isMobileScreen ? <br key="br-1" /> : "", })} </TableHead> <TableHead className="text-center cursor-pointer" onClick={() => { if (setCandidate.size) { // Clear selection and reset form values reset(); form.reset(); } else { // Select all and distribute average votes candidates.forEach(i => add(i.candidateId)); const avgVotes = Math.floor( totalLimit / Math.max(1, candidates.length) ); const values: Record<string, number> = {}; candidates.forEach(c => { values[c.candidateId.toString()] = avgVotes; }); form.setValue("candidates", values); } }} > {t("voteElection.election.table.cumulativeVoting")}{" "} {isMobileScreen && <br key="br-2" />} </TableHead> <TableHead> {t("voteElection.election.table.numberVotes")} </TableHead> </TableRow> </TableHeader> <TableBody> {candidates.map(i => ( <TableRow key={i.candidateId}> <TableCell>{i.fullName}</TableCell> <TableCell className="text-center"> <Checkbox disabled={isDisabled} onCheckedChange={checked => { handleCheckboxChange( i.candidateId, checked === true ); }} checked={has(i.candidateId)} /> </TableCell> <TableCell> <FormField control={form.control} name={`candidates.${i.candidateId}`} render={({ field }) => ( <FormItem> <FormControl> <NumericFormat customInput={Input} placeholder={t( "voteElection.election.table.placeholder" )} className="px-3! py-1.5! min-w-28" disabled={!!setCandidate.size || isDisabled} value={field.value || ""} onValueChange={values => { const { floatValue } = values; field.onChange(floatValue || 0); }} thousandSeparator="," allowNegative={false} decimalScale={0} isAllowed={values => { const { floatValue } = values; return ( floatValue === undefined || floatValue >= 0 ); }} /> </FormControl> </FormItem> )} /> </TableCell> </TableRow> ))} </TableBody> <TableFooter> <TableRow> <TableCell className="text-foreground-05" colSpan={2}> {t("voteElection.election.table.totalVotes")} </TableCell> <TableCell className="text-foreground-05"> {totalVoted} </TableCell> </TableRow> </TableFooter> </Table> </div> </div> ) : ( <ErrorContent errorInfo={data?.errorInfo} className="m-4" /> ) ) : ( <div className="relative min-h-[400px]"> <Skeleton className="h-full rounded-default" /> <LoadingOverlay visible={isLoading} /> </div> )} <VotingElectionFooter onSubmit={onSubmit} isDisabled={isDisabled || isDialogOpen} batchId={data?.data?.batchId} batchName={ language === "vi" ? data?.data?.batchName : data?.data?.batchNameEn } batchType={data?.data?.batchType} /> {showOtpDialog && ( <Suspense fallback={<LoadingOverlay visible={true} />}> <LazyOtpVerificationDialog open={isDialogOpen} onOpenChange={handleCloseDialog} onConfirm={mutationForm} /> </Suspense> )} <CommonDialog open={dialog.open} onOpenChange={(open: boolean) => { if (!open && !dialog.isSuccess) form.clearErrors(); if (!open && dialog.isSuccess && dialog.type !== "none") onOpenChange(false); setDialog({ ...dialog, open }); }} rightButton={ dialog.isSuccess && dialog.type === "none" ? ( <Button className="flex-1" onClick={handleVerifyOtpBeforeSubmit}> {t("voteElection.confirm")} </Button> ) : null } content={dialog.message} iconType={dialog.type} className="md:max-w-sm!" /> </Form> ); }; const VotingItem = ({ item, index, form, length, isDisabled, }: { item: IssuesVoteDTO; index: number; form: UseFormReturn<VotingFormValues>; length: number; isDisabled: boolean; }) => { const { i18n, t } = useTranslation(["overview"]); const language = i18n.language as Language; const { content, description } = getLocalizedProperties( item, ["content", "description"], language ); return ( <div className={cn( "grid grid-cols-1 sm:grid-cols-2 gap-y-2 p-2.5 rounded-none bg-background-06", index < length - 1 && "border-b-2 border-background" )} > <div className="text-foreground-05 text-left flex flex-col gap-1.5"> <h3 className="responsive-text font-semibold">{content}</h3> <p className="responsive-text-sm">{description}</p> </div> <FormField disabled={isDisabled} control={form.control} name={`individualVotes.${item.issueId}`} render={({ field }) => ( <FormItem> <FormControl> <RadioGroup value={field.value} onValueChange={value => { // Clear batch vote when individual vote is changed form.setValue("batchVote", VOTE_OPTIONS.NONE); field.onChange(value); }} className="grid grid-cols-3 gap-5 justify-items-start sm:justify-items-end content-start" > <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.AGREE} id={`approve-${item.issueId}`} /> <Label htmlFor={`approve-${item.issueId}`} className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.agree")} </Label> </div> <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.DISAGREE} id={`disapprove-${item.issueId}`} /> <Label htmlFor={`disapprove-${item.issueId}`} className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.disagree")} </Label> </div> <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.NOIDEA} id={`abstain-${item.issueId}`} /> <Label htmlFor={`abstain-${item.issueId}`} className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.noOption")} </Label> </div> </RadioGroup> </FormControl> </FormItem> )} /> </div> ); }; const VotingElectionFooter = ({ onSubmit, isDisabled, batchId, batchName, batchType, }: { onSubmit?: ( e?: BaseSyntheticEvent<object, any, any> | undefined ) => Promise<void>; isDisabled: boolean; batchId?: string; batchName?: string; batchType?: BatchType; }) => { const form = useFormContext(); const [isLoading, setIsLoading] = useState(false); const { showError } = useError(); const isSubmitting = form.formState.isSubmitting; const { t } = useTranslation(["overview"]); const { i18n } = useTranslation(); const language = i18n.language as Language; const queryClient = useQueryClient(); const [dialog, setDialog] = useState<{ open: boolean; data: MeetingInfoDTO[]; }>({ data: [], open: false, }); const handleFetchGuide = async () => { if (!batchId) return alert("Missing batchId"); const data = await queryClient.fetchQuery( informationQueryOptions(INFO_TYPES.TAILIEU, batchId) ); setDialog({ open: true, data: data.data || [] }); }; const handleDownloadPdf = async (batchId?: string) => { try { if (!batchId) { alert("Missing batchId"); return; } setIsLoading(true); const isVoting = batchType === "BIEUQUYET"; const blobData = await queryClient.fetchQuery( isVoting ? downloadVotingPdf(batchId) : downloadElectionPdf(batchId) ); // The API returns Uint8Array for non-JSON responses (PDFs, etc.) const blob = new Blob([blobData], { type: "application/pdf", }); const blobUrl = URL.createObjectURL(blob); // Create a temporary anchor element to trigger download const link = document.createElement("a"); link.href = blobUrl; const linkName = language === "vi" ? isVoting ? "Giấy biểu quyết" : "Giấy bầu cử" : isVoting ? "Voting form" : "Election form"; link.download = `${linkName} ${batchName}.pdf`; document.body.appendChild(link); link.click(); // Cleanup document.body.removeChild(link); URL.revokeObjectURL(blobUrl); } catch (error) { showError( createErrorWithFallback( error as { errorInfo?: { errorCode?: string; errorMessageVI?: string; errorMessageEN?: string; }; statusCode?: number; }, { showErrorCode: true, } ) ); } finally { setIsLoading(false); } }; return ( <div className="flex flex-col xs:flex-row gap-x-6 gap-y-3 px-4 pb-3"> <div className="flex gap-3 items-center"> <Button type="button" variant="secondary" className="max-h-8 xs:max-h-[unset]" disabled={isLoading} onClick={() => handleDownloadPdf(batchId)} > {!isLoading ? <DownloadIcon /> : <Loader className="animate-spin" />} {t("voteElection.downloadBallot")} </Button> <Button type="button" variant="secondary" className="max-h-8 xs:max-h-[unset]" onClick={handleFetchGuide} > <DocumentIcon className="[&>path]:stroke-primary" /> {t("voteElection.manual")} </Button> </div> {onSubmit && ( <Button type="submit" onClick={onSubmit} className="xs:flex-1" disabled={isDisabled || isSubmitting} > {isSubmitting ? ( <div className="flex items-center justify-center gap-2"> <Loader className="animate-spin flex-1" /> <span>{t("voteElection.confirm") + "..."}</span> </div> ) : ( t("voteElection.confirm") )} </Button> )} <LazyMeetingDocumentDialog open={dialog.open} onOpenChange={open => { if (!open) setDialog({ ...dialog, open }); }} meetingInfos={dialog.data} /> </div> ); }; export default VotingElectionDialog;
You - 10/31/2025, 3:08 AMimport CommonDialog, { type IconType } from "@/components/dialog"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { Form, FormControl, FormField, FormItem } from "@/components/ui/form"; import { Drawer, DrawerClose, DrawerContent, DrawerHeader, DrawerTitle, } from "@/components/ui/drawer"; import DocumentIcon from "@/icons/document-icon"; import DownloadIcon from "@/icons/download-icon"; import HourglassEagerIcon from "@/icons/hourglass-eager-icon"; import { cn } from "@/lib/utils"; import { useForm, useFormContext, useWatch, type UseFormReturn, } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import type { BaseSyntheticEvent } from "react"; import { lazy, Suspense, useEffect, useState } from "react"; import NumberOfShares from "./number-of-shares"; import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { useSet, useUpdateEffect } from "react-use"; import { ScrollArea } from "@/components/ui/scroll-area"; import { NumericFormat } from "react-number-format"; import type { BatchCandidatesResponse, BatchIssuesResponse, IssuesBatchResponse, IssuesVoteDTO, MeetingInfoDTO, } from "@/types/information.type"; import { BATCH_TYPES, INFO_TYPES, VOTE_OPTIONS, type BatchType, type VoteOption, type VoteStatus, } from "@/constants/params"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { candidatesByBatchIdQueryOptions, informationQueryOptions, issuesByBatchIdQueryOptions, } from "@/features/information/information.api"; import { getLocalizedProperties, isHttpStatusSuccess } from "@/lib/helper"; import { useTranslation } from "react-i18next"; import type { Language } from "@/constants/language"; import { format } from "date-fns"; import { TIME_FORMAT } from "@/constants/format"; import type { ApiResponse } from "@/types/api.type"; import { Skeleton } from "@/components/ui/skeleton"; import { LoadingOverlay } from "@/components/ui/loading-overlay"; import { useMediaQuery } from "react-responsive"; import CancelIcon from "@/icons/cancel-icon"; import NoContent from "@/components/no-content"; import ErrorContent from "@/components/error-content"; import { downloadElectionPdf, downloadVotingPdf, voteCandidatesOptions, voteIssuesOptions, } from "@/features/meeting/meeting.api"; import { useError } from "@/contexts/error-provider"; import { createErrorWithFallback } from "@/lib/error-utils"; import type { RefetchProps } from "../$meetId/-components/batch-list"; import useAuthApi from "@/features/auth/auth.api"; import { Loader } from "lucide-react"; const LazyMeetingDocumentDialog = lazy( () => import("@/components/meeting-document-dialog") ); const LazyOtpVerificationDialog = lazy(() => import("@/components/otp-dialog")); interface IProps { open: boolean; onOpenChange: (open: boolean) => void; batch: IssuesBatchResponse; onShowResultDialog: (dialog: { open: boolean; content: string }) => void; refetchBatches: RefetchProps; } type DialogType = { open: boolean; isSuccess: boolean; message: React.ReactNode; type: IconType; }; export function getBadgeColorByStatus(status: VoteStatus) { switch (status) { case "CHOBOPHIEU": return "bg-primary-03 text-white"; case "DABOPHIEU": return "bg-primary text-white"; default: return "bg-background-07 text-foreground-09 [&_svg_path]:!fill-[#c5c5c5] [&_svg_path]:!stroke-[#c5c5c5]"; } } // Function to create election schema with dynamic total limit const createElectionSchema = (totalLimit: number) => z .object({ candidates: z.record( z.string(), z.number().min(0, "voteElection.election.error.minVote") ), }) .refine( data => { const total = Object.values(data.candidates).reduce( (sum, votes) => sum + votes, 0 ); return total <= totalLimit; }, { message: "voteElection.election.error.maxVote", path: ["candidates"], } ); type ElectionFormValues = z.infer<ReturnType<typeof createElectionSchema>>; // Form schema for voting const votingSchema = z.object({ batchVote: z .enum([ VOTE_OPTIONS.AGREE, VOTE_OPTIONS.DISAGREE, VOTE_OPTIONS.NOIDEA, VOTE_OPTIONS.NONE, VOTE_OPTIONS.INVALID, ]) .optional(), individualVotes: z .record( z.string(), z.enum([ VOTE_OPTIONS.AGREE, VOTE_OPTIONS.DISAGREE, VOTE_OPTIONS.NOIDEA, VOTE_OPTIONS.NONE, VOTE_OPTIONS.INVALID, ]) ) .refine( record => { const selectedValues = Object.values(record); // Return expected result return selectedValues.every(value => value !== VOTE_OPTIONS.NONE); }, { error: "voteElection.vote.error" } ), }); type VotingFormValues = z.infer<typeof votingSchema>; const VotingElectionDialog = ({ open, onOpenChange, batch, onShowResultDialog, refetchBatches, }: IProps) => { const isElection = batch.batchType !== BATCH_TYPES.BIEUQUYET; const isMobile = useMediaQuery({ maxWidth: 768 }); // Use separate queries for different batch types - only run when dialog is actually open const issuesOptions = issuesByBatchIdQueryOptions(batch.batchId); const issuesQuery = useQuery({ ...issuesOptions, enabled: !isElection && open, queryKey: [...issuesOptions.queryKey, String(open)], // Refetch when dialog opens refetchOnMount: false, }); const candidatesOptions = candidatesByBatchIdQueryOptions(batch.batchId); const candidatesQuery = useQuery({ ...candidatesOptions, enabled: isElection && open, queryKey: [...candidatesOptions.queryKey, String(open)], // Refetch when dialog opens refetchOnMount: false, }); const { i18n } = useTranslation(); const language = i18n.language as Language; const data = isElection ? candidatesQuery.data : issuesQuery.data; const isLoading = isElection ? candidatesQuery.isLoading : issuesQuery.isLoading; const { batchName } = getLocalizedProperties(batch, ["batchName"], language); const contentComponent = !isElection ? ( <VotingContent onOpenChange={onOpenChange} isLoading={isLoading} data={data as ApiResponse<BatchIssuesResponse | null>} onShowResultDialog={onShowResultDialog} invalidateBatchesQuery={refetchBatches} /> ) : ( <ElectionContent onOpenChange={onOpenChange} data={data as ApiResponse<BatchCandidatesResponse | null>} isLoading={isLoading} onShowResultDialog={onShowResultDialog} invalidateBatchesQuery={refetchBatches} /> ); const headerComponent = <VotingElectionHeader batch={batch} />; if (isMobile) { return ( <Drawer open={open} onOpenChange={onOpenChange}> <DrawerContent className="max-h-[90vh]!"> <DrawerHeader className="border-b-2 border-background-02 gap-2"> <div className="flex items-center justify-between"> <DrawerTitle className="text-left text-foreground-12 font-bold responsive-text-3xl"> {batchName} </DrawerTitle> <DrawerClose asChild> <Button variant="ghost" className="size-6 p-0!"> <CancelIcon className="size-6" /> <span className="sr-only">Close</span> </Button> </DrawerClose> </div> {headerComponent} </DrawerHeader> <div className="flex-1 overflow-auto">{contentComponent}</div> </DrawerContent> </Drawer> ); } return ( <CommonDialog noTrans title={batchName} content={contentComponent} open={open} className={cn( "max-w-[360px] xs:max-w-[600px] sm:max-w-[800px] gap-0", "p-0! [&>[data-slot='dialog-header']]:p-4 [&>[data-slot='dialog-header']]:border-b-2 [&>[data-slot='dialog-header']]:border-background-02 [&>[data-slot='dialog-close']]:top-4 [&>[data-slot='dialog-close']]:right-4" )} showCloseBtn={false} onOpenChange={onOpenChange} header={headerComponent} /> ); }; const VotingElectionHeader = ({ batch }: { batch: IssuesBatchResponse }) => { const { t } = useTranslation(["overview"]); return ( <div className="flex items-stretch justify-between"> <Badge variant="outlineDestructive" className="h-[unset]"> <HourglassEagerIcon className="!size-4" /> {t("voteElection.endTime", { endTime: batch.endTime && format(batch.endTime, TIME_FORMAT), })} </Badge> <Button isSmall variant="secondary" className={cn( "max-h-8 p-1.5 responsive-text-sm", getBadgeColorByStatus(batch.votingStatus) )} > {batch.votingStatus == "CHOBOPHIEU" ? t("voteElection.status.waiting") : batch.votingStatus == "DABOPHIEU" ? t("voteElection.status.voted") : t("voteElection.status.closed")} </Button> </div> ); }; const VotingContent = ({ onOpenChange, isLoading, data, onShowResultDialog, invalidateBatchesQuery, }: { onOpenChange: (open: boolean) => void; isLoading: boolean; data: ApiResponse<BatchIssuesResponse | null>; onShowResultDialog: (open: { open: boolean; content: string }) => void; invalidateBatchesQuery: RefetchProps; }) => { const { sendOtpVerify } = useAuthApi(); const { showError } = useError(); const { t, i18n } = useTranslation(["overview"]); const [showOtpDialog, setShowOtpDialog] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false); const language = i18n.language as Language; const form = useForm<VotingFormValues>({ resolver: zodResolver(votingSchema), defaultValues: { batchVote: VOTE_OPTIONS.NONE, individualVotes: {}, }, }); const isVerifyOtp = data?.data?.isRequestOtp; const isDisabled = !data?.data?.isAllowedToVote; const handleOpenDialog = () => { setShowOtpDialog(true); setIsDialogOpen(true); }; const handleCloseDialog = (open: boolean) => { setIsDialogOpen(open); }; // Side effect to initialize form values when data changes useEffect(() => { if (data?.data?.votingIssues) { const initialVotes = data.data.votingIssues.reduce< Record<string, VoteOption> >((acc, cur) => { acc[cur.issueId.toString()] = cur.eVote; return acc; }, {}); // Determine initial batch vote const votes = Object.values(initialVotes); const initialBatchVote = votes.length > 0 && votes.every(vote => vote === votes[0] && vote !== VOTE_OPTIONS.NONE) ? votes[0] : VOTE_OPTIONS.NONE; // Update form values form.setValue("individualVotes", initialVotes); form.setValue("batchVote", initialBatchVote); } }, [data?.data?.votingIssues, form]); const addVoteIssuesMutation = useMutation(voteIssuesOptions()); const [dialog, setDialog] = useState<DialogType>({ open: false, isSuccess: true, message: "", type: "none", }); const handleSubmit = async (params: VotingFormValues) => { // Handle voting form submission console.log("Voting data:", params); if (isVerifyOtp) { sendOtpVerify.mutate(undefined, { onSuccess: function ({ statusCode, ticketId, errorInfo }) { if (isHttpStatusSuccess(statusCode)) { handleOpenDialog(); } else { showError( createErrorWithFallback( { errorInfo, statusCode, }, { details: ticketId || undefined, showErrorCode: true } ) ); } }, }); return; } mutationForm(); }; const onSubmit = form.handleSubmit(handleSubmit); const mutationForm = async (otp?: string) => { if (!data.data) return; const { errorInfo, statusCode, ticketId } = await addVoteIssuesMutation.mutateAsync({ batchId: data.data.batchId, shareQtyConfirm: data.data.shareQtyConfirm, issues: Object.entries(form.getValues().individualVotes).map( ([issueId, vote]) => ({ issueId: Number(issueId), eVote: vote, }) ), otp, }); if (!errorInfo && isHttpStatusSuccess(statusCode)) { await invalidateBatchesQuery(); onShowResultDialog({ open: true, content: t("voteElection.vote.success"), }); handleCloseDialog(false); onOpenChange(false); } else { showError( createErrorWithFallback( { errorInfo, statusCode, }, { details: ticketId || undefined, showErrorCode: true } ) ); } }; // Watch for batchVote changes and update all individual votes const batchVote = useWatch({ control: form.control, name: "batchVote" }); useEffect(() => { // Only update individual votes when batchVote has a valid value (not empty string) if (batchVote !== VOTE_OPTIONS.NONE && batchVote !== undefined) { const newIndividualVotes = data.data?.votingIssues?.reduce( (acc, item) => { acc[item.issueId.toString()] = batchVote; return acc; }, {} as Record<string, VoteOption> ); if (newIndividualVotes) { form.setValue("individualVotes", newIndividualVotes); } } }, [batchVote, form]); // Handle form errors useEffect(() => { if (Object.keys(form.formState.errors).length > 0) { // Handle errors here const errors = Object.values(form.formState.errors); const message = errors.map((err, i) => { const code = err.message?.toString() || err.root?.message; return ( <p key={i}> {errors.length > 1 && "• "} {t(code)} </p> ); }); setDialog({ open: true, message, isSuccess: false, type: "error" }); } }, [form.formState.errors]); return ( <Form {...form}> <div className="px-4 py-3.5"> <div className="flex flex-col gap-3"> {/* Bỏ phiếu hàng loạt */} <div className="grid grid-cols-1 gap-y-2 xs:grid-cols-3 sm:grid-cols-2 px-2.5"> <h2 className="responsive-text text-primary font-semibold text-left"> {t("voteElection.vote.title")} </h2> <FormField disabled={isDisabled} control={form.control} name="batchVote" render={({ field }) => ( <FormItem className="xs:col-span-2 sm:col-span-1"> <FormControl> <RadioGroup value={field.value} onValueChange={field.onChange} className="grid grid-cols-3 gap-5 justify-items-start xs:justify-items-end" > <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.AGREE} id="batch-approve" /> <Label htmlFor="batch-approve" className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.agree")} </Label> </div> <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.DISAGREE} id="batch-disapprove" /> <Label htmlFor="batch-disapprove" className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.disagree")} </Label> </div> <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.NOIDEA} id="batch-abstain" /> <Label htmlFor="batch-abstain" className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.noOption")} </Label> </div> </RadioGroup> </FormControl> </FormItem> )} /> </div> {/* Bỏ phiếu từng biểu quyết */} {!isLoading ? ( data?.data ? ( <ScrollArea className="rounded-default"> {data.data.votingIssues ? ( data.data.votingIssues?.map((item, index) => ( <VotingItem key={item.issueId} item={item} isDisabled={isDisabled} length={data.data?.votingIssues?.length || 0} index={index} form={form} /> )) ) : ( <NoContent /> )} </ScrollArea> ) : ( <ErrorContent errorInfo={data?.errorInfo} className="m-4" /> ) ) : ( <div className="relative min-h-[150px]"> <Skeleton className="h-full rounded-default" /> <LoadingOverlay visible={isLoading} /> </div> )} </div> </div> <VotingElectionFooter onSubmit={onSubmit} isDisabled={isDisabled || isDialogOpen} batchId={data?.data?.batchId} batchName={ language === "vi" ? data?.data?.batchName : data?.data?.batchNameEn } batchType={data?.data?.batchType} /> <CommonDialog className="md:max-w-sm!" open={dialog.open} onOpenChange={(open: boolean) => { if (!open && !dialog.isSuccess) form.clearErrors(); if (!open && dialog.isSuccess && dialog.type !== "none") onOpenChange(false); setDialog({ ...dialog, open }); }} content={dialog.message} iconType={dialog.isSuccess ? "success" : "error"} /> {showOtpDialog && ( <Suspense fallback={<LoadingOverlay visible={true} />}> <LazyOtpVerificationDialog open={isDialogOpen} onOpenChange={handleCloseDialog} onConfirm={mutationForm} /> </Suspense> )} </Form> ); }; const ElectionContent = ({ onOpenChange, data, isLoading, onShowResultDialog, invalidateBatchesQuery, }: { onOpenChange: (open: boolean) => void; data: ApiResponse<BatchCandidatesResponse | null>; isLoading: boolean; onShowResultDialog: (open: { open: boolean; content: string }) => void; invalidateBatchesQuery: RefetchProps; }) => { const { sendOtpVerify } = useAuthApi(); const { showError } = useError(); const { t, i18n } = useTranslation(["overview"]); const [showOtpDialog, setShowOtpDialog] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false); const language = i18n.language as Language; const isVerifyOtp = data?.data?.isRequestOtp; // Safe access to data with loading check const totalLimit = (!isLoading && data?.data?.candidateShareholder?.totalVotes) || 0; const isDisabled = !data?.data?.isAllowedToVote; // Create dynamic schema with the total limit from API const dynamicElectionSchema = createElectionSchema(totalLimit); const form = useForm<ElectionFormValues>({ resolver: zodResolver(dynamicElectionSchema), defaultValues: { candidates: {}, }, }); const handleOpenDialog = () => { setShowOtpDialog(true); setIsDialogOpen(true); }; const handleCloseDialog = (open: boolean) => { setIsDialogOpen(open); }; // Side effect to initialize form values when data changes useEffect(() => { if (!isLoading && data?.data?.votingCandidates) { const initialValues: Record<string, number> = {}; data.data.votingCandidates.forEach(candidate => { initialValues[candidate.candidateId.toString()] = candidate.ballotCounter || 0; }); form.setValue("candidates", initialValues); } }, [data?.data?.votingCandidates, isLoading]); const addVoteCandidatesMutation = useMutation(voteCandidatesOptions()); const [dialog, setDialog] = useState<DialogType>({ open: false, isSuccess: true, message: "", type: "none", }); const isMobileScreen = useMediaQuery({ maxWidth: 480 }); // Watch candidates form values and calculate total voted dynamically using useWatch (form.watch doesn't work) const watchedCandidates = useWatch({ control: form.control, name: "candidates", }) as Record<string, number> | undefined; const totalVoted = Object.values(watchedCandidates || {}).reduce( (sum, v) => sum + (typeof v === "number" ? v : Number(v || 0)), 0 ); const handleSubmit = async (data: ElectionFormValues) => { // Handle election form submission console.log("Election data:", data); if (totalVoted <= 0) { setDialog({ message: t("voteElection.election.error.minVote"), open: true, type: "error", isSuccess: false, }); return; } if (totalVoted < totalLimit) { setDialog({ message: t("voteElection.election.error.remainVote", { remaining: totalLimit - totalVoted, }), open: true, type: "none", isSuccess: true, }); return; } handleVerifyOtpBeforeSubmit(); }; const handleVerifyOtpBeforeSubmit = () => { if (isVerifyOtp) { sendOtpVerify.mutate(undefined, { onSuccess: function ({ statusCode, ticketId, errorInfo }) { if (isHttpStatusSuccess(statusCode)) { handleOpenDialog(); } else { showError( createErrorWithFallback( { errorInfo, statusCode, }, { details: ticketId || undefined, showErrorCode: true } ) ); } }, }); return; } mutationForm(); }; const onSubmit = form.handleSubmit(handleSubmit); const mutationForm = async (otp?: string) => { if (!data.data) return; const formData = form.getValues(); const { errorInfo, statusCode, ticketId } = await addVoteCandidatesMutation.mutateAsync({ batchId: data.data.batchId, batchType: data.data.batchType, candidates: Object.entries(formData.candidates).map( ([candidateId, ballotCounter]) => ({ candidateId: Number(candidateId), ballotCounter, }) ), otp, }); if (!errorInfo && isHttpStatusSuccess(statusCode)) { await invalidateBatchesQuery(); onShowResultDialog({ open: true, content: t("voteElection.election.success"), }); handleCloseDialog(false); onOpenChange(false); } else { showError( createErrorWithFallback( { errorInfo, statusCode, }, { details: ticketId || undefined, showErrorCode: true } ) ); } }; const candidates = (!isLoading && data?.data?.votingCandidates) || []; const [setCandidate, { add, has, remove, reset }] = useSet(new Set<number>()); // Initialize checkboxes for candidates with existing votes (ballotCounter > 0) useUpdateEffect(() => { if (!isLoading && candidates.length > 0) { // Clear existing selections first reset(); // Calculate candidates with votes > 0 const candidatesWithVotes = candidates.filter( candidate => candidate.ballotCounter > 0 ); if (candidatesWithVotes.length > 0) { // Calculate average votes per candidate with votes const avgVotesPerCandidate = Math.floor( totalLimit / candidatesWithVotes.length ); // Only check candidates whose ballotCounter equals the average candidates.forEach(candidate => { if (candidate.ballotCounter === avgVotesPerCandidate) { add(candidate.candidateId); } }); } // Initialize form values with existing ballotCounter values (preserve actual data) const formValues: Record<string, number> = {}; candidates.forEach(candidate => { formValues[candidate.candidateId.toString()] = candidate.ballotCounter || 0; }); form.setValue("candidates", formValues); } }, [candidates, isLoading, totalLimit]); // Handle manual checkbox changes by user interaction const handleCheckboxChange = (candidateId: number, checked: boolean) => { // compute previous size before mutating the set to avoid off-by-one const prevSize = setCandidate.size; if (checked) { add(candidateId); } else { remove(candidateId); } // Calculate new selection set size after the change const newSelectionSize = checked ? prevSize + 1 : Math.max(0, prevSize - 1); if (newSelectionSize > 0) { const avgVotes = Math.floor(totalLimit / newSelectionSize); const formValues: Record<string, number> = {}; candidates.forEach(candidate => { const willBeSelected = candidate.candidateId === candidateId ? checked : has(candidate.candidateId); formValues[candidate.candidateId.toString()] = willBeSelected ? avgVotes : 0; }); form.setValue("candidates", formValues); } else { // Reset all values to 0 when no candidates are selected const formValues: Record<string, number> = {}; candidates.forEach(candidate => { formValues[candidate.candidateId.toString()] = 0; }); form.setValue("candidates", formValues); } }; return ( <Form {...form}> {!isLoading ? ( data?.data ? ( <div className="flex flex-col gap-3.5 px-4 pt-2.5 pb-3"> <div className="flex flex-col gap-2"> <h2 className="responsive-text text-primary font-semibold text-left"> {t("voteElection.election.numElectionInfo")} </h2> <div className="flex max-sm:overflow-x-auto max-sm:overflow-y-hidden gap-3 pb-1"> <div className="max-sm:flex-shrink-0 max-w-40 xs:max-w-52 sm:max-w-64 lg:max-w-none"> <NumberOfShares title={t("shares.shareQtyConfirm")} count={ data?.data.candidateShareholder?.shareQtyConfirm || 0 } /> </div> <div className="max-sm:flex-shrink-0 max-w-40 xs:max-w-52 sm:max-w-64 lg:max-w-none"> <NumberOfShares title={t("shares.candidateCounter")} count={ data?.data.candidateShareholder?.candidateCounter || 0 } /> </div> <div className="max-sm:flex-shrink-0 max-w-40 xs:max-w-52 sm:max-w-64 lg:max-w-none"> <NumberOfShares title={t("shares.totalVotes")} count={data?.data.candidateShareholder?.totalVotes || 0} /> </div> </div> </div> <div className="flex flex-col gap-2 mb-4 rounded-lg"> <h2 className="responsive-text text-primary font-semibold text-left"> {t("voteElection.election.infoCandidate")} </h2> <Table className="text-left"> <TableHeader> <TableRow> <TableHead> {t("voteElection.election.table.candidateName", { tag: isMobileScreen ? <br key="br-1" /> : "", })} </TableHead> <TableHead className="text-center cursor-pointer" onClick={() => { if (setCandidate.size) { // Clear selection and reset form values reset(); form.reset(); } else { // Select all and distribute average votes candidates.forEach(i => add(i.candidateId)); const avgVotes = Math.floor( totalLimit / Math.max(1, candidates.length) ); const values: Record<string, number> = {}; candidates.forEach(c => { values[c.candidateId.toString()] = avgVotes; }); form.setValue("candidates", values); } }} > {t("voteElection.election.table.cumulativeVoting")}{" "} {isMobileScreen && <br key="br-2" />} </TableHead> <TableHead> {t("voteElection.election.table.numberVotes")} </TableHead> </TableRow> </TableHeader> <TableBody> {candidates.map(i => ( <TableRow key={i.candidateId}> <TableCell>{i.fullName}</TableCell> <TableCell className="text-center"> <Checkbox disabled={isDisabled} onCheckedChange={checked => { handleCheckboxChange( i.candidateId, checked === true ); }} checked={has(i.candidateId)} /> </TableCell> <TableCell> <FormField control={form.control} name={`candidates.${i.candidateId}`} render={({ field }) => ( <FormItem> <FormControl> <NumericFormat customInput={Input} placeholder={t( "voteElection.election.table.placeholder" )} className="px-3! py-1.5! min-w-28" disabled={!!setCandidate.size || isDisabled} value={field.value || ""} onValueChange={values => { const { floatValue } = values; field.onChange(floatValue || 0); }} thousandSeparator="," allowNegative={false} decimalScale={0} isAllowed={values => { const { floatValue } = values; return ( floatValue === undefined || floatValue >= 0 ); }} /> </FormControl> </FormItem> )} /> </TableCell> </TableRow> ))} </TableBody> <TableFooter> <TableRow> <TableCell className="text-foreground-05" colSpan={2}> {t("voteElection.election.table.totalVotes")} </TableCell> <TableCell className="text-foreground-05"> {totalVoted} </TableCell> </TableRow> </TableFooter> </Table> </div> </div> ) : ( <ErrorContent errorInfo={data?.errorInfo} className="m-4" /> ) ) : ( <div className="relative min-h-[400px]"> <Skeleton className="h-full rounded-default" /> <LoadingOverlay visible={isLoading} /> </div> )} <VotingElectionFooter onSubmit={onSubmit} isDisabled={isDisabled || isDialogOpen} batchId={data?.data?.batchId} batchName={ language === "vi" ? data?.data?.batchName : data?.data?.batchNameEn } batchType={data?.data?.batchType} /> {showOtpDialog && ( <Suspense fallback={<LoadingOverlay visible={true} />}> <LazyOtpVerificationDialog open={isDialogOpen} onOpenChange={handleCloseDialog} onConfirm={mutationForm} /> </Suspense> )} <CommonDialog open={dialog.open} onOpenChange={(open: boolean) => { if (!open && !dialog.isSuccess) form.clearErrors(); if (!open && dialog.isSuccess && dialog.type !== "none") onOpenChange(false); setDialog({ ...dialog, open }); }} rightButton={ dialog.isSuccess && dialog.type === "none" ? ( <Button className="flex-1" onClick={handleVerifyOtpBeforeSubmit}> {t("voteElection.confirm")} </Button> ) : null } content={dialog.message} iconType={dialog.type} className="md:max-w-sm!" /> </Form> ); }; const VotingItem = ({ item, index, form, length, isDisabled, }: { item: IssuesVoteDTO; index: number; form: UseFormReturn<VotingFormValues>; length: number; isDisabled: boolean; }) => { const { i18n, t } = useTranslation(["overview"]); const language = i18n.language as Language; const { content, description } = getLocalizedProperties( item, ["content", "description"], language ); return ( <div className={cn( "grid grid-cols-1 sm:grid-cols-2 gap-y-2 p-2.5 rounded-none bg-background-06", index < length - 1 && "border-b-2 border-background" )} > <div className="text-foreground-05 text-left flex flex-col gap-1.5"> <h3 className="responsive-text font-semibold">{content}</h3> <p className="responsive-text-sm">{description}</p> </div> <FormField disabled={isDisabled} control={form.control} name={`individualVotes.${item.issueId}`} render={({ field }) => ( <FormItem> <FormControl> <RadioGroup value={field.value} onValueChange={value => { // Clear batch vote when individual vote is changed form.setValue("batchVote", VOTE_OPTIONS.NONE); field.onChange(value); }} className="grid grid-cols-3 gap-5 justify-items-start sm:justify-items-end content-start" > <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.AGREE} id={`approve-${item.issueId}`} /> <Label htmlFor={`approve-${item.issueId}`} className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.agree")} </Label> </div> <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.DISAGREE} id={`disapprove-${item.issueId}`} /> <Label htmlFor={`disapprove-${item.issueId}`} className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.disagree")} </Label> </div> <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.NOIDEA} id={`abstain-${item.issueId}`} /> <Label htmlFor={`abstain-${item.issueId}`} className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.noOption")} </Label> </div> </RadioGroup> </FormControl> </FormItem> )} /> </div> ); }; const VotingElectionFooter = ({ onSubmit, isDisabled, batchId, batchName, batchType, }: { onSubmit?: ( e?: BaseSyntheticEvent<object, any, any> | undefined ) => Promise<void>; isDisabled: boolean; batchId?: string; batchName?: string; batchType?: BatchType; }) => { const form = useFormContext(); const [isLoading, setIsLoading] = useState(false); const { showError } = useError(); const isSubmitting = form.formState.isSubmitting; const { t } = useTranslation(["overview"]); const { i18n } = useTranslation(); const language = i18n.language as Language; const queryClient = useQueryClient(); const [dialog, setDialog] = useState<{ open: boolean; data: MeetingInfoDTO[]; }>({ data: [], open: false, }); const handleFetchGuide = async () => { if (!batchId) return alert("Missing batchId"); const data = await queryClient.fetchQuery( informationQueryOptions(INFO_TYPES.TAILIEU, batchId) ); setDialog({ open: true, data: data.data || [] }); }; const handleDownloadPdf = async (batchId?: string) => { try { if (!batchId) { alert("Missing batchId"); return; } setIsLoading(true); const isVoting = batchType === "BIEUQUYET"; const blobData = await queryClient.fetchQuery( isVoting ? downloadVotingPdf(batchId) : downloadElectionPdf(batchId) ); // The API returns Uint8Array for non-JSON responses (PDFs, etc.) const blob = new Blob([blobData], { type: "application/pdf", }); const blobUrl = URL.createObjectURL(blob); // Create a temporary anchor element to trigger download const link = document.createElement("a"); link.href = blobUrl; const linkName = language === "vi" ? isVoting ? "Giấy biểu quyết" : "Giấy bầu cử" : isVoting ? "Voting form" : "Election form"; link.download = `${linkName} ${batchName}.pdf`; document.body.appendChild(link); link.click(); // Cleanup document.body.removeChild(link); URL.revokeObjectURL(blobUrl); } catch (error) { showError( createErrorWithFallback( error as { errorInfo?: { errorCode?: string; errorMessageVI?: string; errorMessageEN?: string; }; statusCode?: number; }, { showErrorCode: true, } ) ); } finally { setIsLoading(false); } }; return ( <div className="flex flex-col xs:flex-row gap-x-6 gap-y-3 px-4 pb-3"> <div className="flex gap-3 items-center"> <Button type="button" variant="secondary" className="max-h-8 xs:max-h-[unset]" disabled={isLoading} onClick={() => handleDownloadPdf(batchId)} > {!isLoading ? <DownloadIcon /> : <Loader className="animate-spin" />} {t("voteElection.downloadBallot")} </Button> <Button type="button" variant="secondary" className="max-h-8 xs:max-h-[unset]" onClick={handleFetchGuide} > <DocumentIcon className="[&>path]:stroke-primary" /> {t("voteElection.manual")} </Button> </div> {onSubmit && ( <Button type="submit" onClick={onSubmit} className="xs:flex-1" disabled={isDisabled || isSubmitting} > {isSubmitting ? ( <div className="flex items-center justify-center gap-2"> <Loader className="animate-spin flex-1" /> <span>{t("voteElection.confirm") + "..."}</span> </div> ) : ( t("voteElection.confirm") )} </Button> )} <LazyMeetingDocumentDialog open={dialog.open} onOpenChange={open => { if (!open) setDialog({ ...dialog, open }); }} meetingInfos={dialog.data} /> </div> ); }; export default VotingElectionDialog;asdasimport CommonDialog, { type IconType } from "@/components/dialog"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { Form, FormControl, FormField, FormItem } from "@/components/ui/form"; import { Drawer, DrawerClose, DrawerContent, DrawerHeader, DrawerTitle, } from "@/components/ui/drawer"; import DocumentIcon from "@/icons/document-icon"; import DownloadIcon from "@/icons/download-icon"; import HourglassEagerIcon from "@/icons/hourglass-eager-icon"; import { cn } from "@/lib/utils"; import { useForm, useFormContext, useWatch, type UseFormReturn, } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import type { BaseSyntheticEvent } from "react"; import { lazy, Suspense, useEffect, useState } from "react"; import NumberOfShares from "./number-of-shares"; import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { useSet, useUpdateEffect } from "react-use"; import { ScrollArea } from "@/components/ui/scroll-area"; import { NumericFormat } from "react-number-format"; import type { BatchCandidatesResponse, BatchIssuesResponse, IssuesBatchResponse, IssuesVoteDTO, MeetingInfoDTO, } from "@/types/information.type"; import { BATCH_TYPES, INFO_TYPES, VOTE_OPTIONS, type BatchType, type VoteOption, type VoteStatus, } from "@/constants/params"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { candidatesByBatchIdQueryOptions, informationQueryOptions, issuesByBatchIdQueryOptions, } from "@/features/information/information.api"; import { getLocalizedProperties, isHttpStatusSuccess } from "@/lib/helper"; import { useTranslation } from "react-i18next"; import type { Language } from "@/constants/language"; import { format } from "date-fns"; import { TIME_FORMAT } from "@/constants/format"; import type { ApiResponse } from "@/types/api.type"; import { Skeleton } from "@/components/ui/skeleton"; import { LoadingOverlay } from "@/components/ui/loading-overlay"; import { useMediaQuery } from "react-responsive"; import CancelIcon from "@/icons/cancel-icon"; import NoContent from "@/components/no-content"; import ErrorContent from "@/components/error-content"; import { downloadElectionPdf, downloadVotingPdf, voteCandidatesOptions, voteIssuesOptions, } from "@/features/meeting/meeting.api"; import { useError } from "@/contexts/error-provider"; import { createErrorWithFallback } from "@/lib/error-utils"; import type { RefetchProps } from "../$meetId/-components/batch-list"; import useAuthApi from "@/features/auth/auth.api"; import { Loader } from "lucide-react"; const LazyMeetingDocumentDialog = lazy( () => import("@/components/meeting-document-dialog") ); const LazyOtpVerificationDialog = lazy(() => import("@/components/otp-dialog")); interface IProps { open: boolean; onOpenChange: (open: boolean) => void; batch: IssuesBatchResponse; onShowResultDialog: (dialog: { open: boolean; content: string }) => void; refetchBatches: RefetchProps; } type DialogType = { open: boolean; isSuccess: boolean; message: React.ReactNode; type: IconType; }; export function getBadgeColorByStatus(status: VoteStatus) { switch (status) { case "CHOBOPHIEU": return "bg-primary-03 text-white"; case "DABOPHIEU": return "bg-primary text-white"; default: return "bg-background-07 text-foreground-09 [&_svg_path]:!fill-[#c5c5c5] [&_svg_path]:!stroke-[#c5c5c5]"; } } // Function to create election schema with dynamic total limit const createElectionSchema = (totalLimit: number) => z .object({ candidates: z.record( z.string(), z.number().min(0, "voteElection.election.error.minVote") ), }) .refine( data => { const total = Object.values(data.candidates).reduce( (sum, votes) => sum + votes, 0 ); return total <= totalLimit; }, { message: "voteElection.election.error.maxVote", path: ["candidates"], } ); type ElectionFormValues = z.infer<ReturnType<typeof createElectionSchema>>; // Form schema for voting const votingSchema = z.object({ batchVote: z .enum([ VOTE_OPTIONS.AGREE, VOTE_OPTIONS.DISAGREE, VOTE_OPTIONS.NOIDEA, VOTE_OPTIONS.NONE, VOTE_OPTIONS.INVALID, ]) .optional(), individualVotes: z .record( z.string(), z.enum([ VOTE_OPTIONS.AGREE, VOTE_OPTIONS.DISAGREE, VOTE_OPTIONS.NOIDEA, VOTE_OPTIONS.NONE, VOTE_OPTIONS.INVALID, ]) ) .refine( record => { const selectedValues = Object.values(record); // Return expected result return selectedValues.every(value => value !== VOTE_OPTIONS.NONE); }, { error: "voteElection.vote.error" } ), }); type VotingFormValues = z.infer<typeof votingSchema>; const VotingElectionDialog = ({ open, onOpenChange, batch, onShowResultDialog, refetchBatches, }: IProps) => { const isElection = batch.batchType !== BATCH_TYPES.BIEUQUYET; const isMobile = useMediaQuery({ maxWidth: 768 }); // Use separate queries for different batch types - only run when dialog is actually open const issuesOptions = issuesByBatchIdQueryOptions(batch.batchId); const issuesQuery = useQuery({ ...issuesOptions, enabled: !isElection && open, queryKey: [...issuesOptions.queryKey, String(open)], // Refetch when dialog opens refetchOnMount: false, }); const candidatesOptions = candidatesByBatchIdQueryOptions(batch.batchId); const candidatesQuery = useQuery({ ...candidatesOptions, enabled: isElection && open, queryKey: [...candidatesOptions.queryKey, String(open)], // Refetch when dialog opens refetchOnMount: false, }); const { i18n } = useTranslation(); const language = i18n.language as Language; const data = isElection ? candidatesQuery.data : issuesQuery.data; const isLoading = isElection ? candidatesQuery.isLoading : issuesQuery.isLoading; const { batchName } = getLocalizedProperties(batch, ["batchName"], language); const contentComponent = !isElection ? ( <VotingContent onOpenChange={onOpenChange} isLoading={isLoading} data={data as ApiResponse<BatchIssuesResponse | null>} onShowResultDialog={onShowResultDialog} invalidateBatchesQuery={refetchBatches} /> ) : ( <ElectionContent onOpenChange={onOpenChange} data={data as ApiResponse<BatchCandidatesResponse | null>} isLoading={isLoading} onShowResultDialog={onShowResultDialog} invalidateBatchesQuery={refetchBatches} /> ); const headerComponent = <VotingElectionHeader batch={batch} />; if (isMobile) { return ( <Drawer open={open} onOpenChange={onOpenChange}> <DrawerContent className="max-h-[90vh]!"> <DrawerHeader className="border-b-2 border-background-02 gap-2"> <div className="flex items-center justify-between"> <DrawerTitle className="text-left text-foreground-12 font-bold responsive-text-3xl"> {batchName} </DrawerTitle> <DrawerClose asChild> <Button variant="ghost" className="size-6 p-0!"> <CancelIcon className="size-6" /> <span className="sr-only">Close</span> </Button> </DrawerClose> </div> {headerComponent} </DrawerHeader> <div className="flex-1 overflow-auto">{contentComponent}</div> </DrawerContent> </Drawer> ); } return ( <CommonDialog noTrans title={batchName} content={contentComponent} open={open} className={cn( "max-w-[360px] xs:max-w-[600px] sm:max-w-[800px] gap-0", "p-0! [&>[data-slot='dialog-header']]:p-4 [&>[data-slot='dialog-header']]:border-b-2 [&>[data-slot='dialog-header']]:border-background-02 [&>[data-slot='dialog-close']]:top-4 [&>[data-slot='dialog-close']]:right-4" )} showCloseBtn={false} onOpenChange={onOpenChange} header={headerComponent} /> ); }; const VotingElectionHeader = ({ batch }: { batch: IssuesBatchResponse }) => { const { t } = useTranslation(["overview"]); return ( <div className="flex items-stretch justify-between"> <Badge variant="outlineDestructive" className="h-[unset]"> <HourglassEagerIcon className="!size-4" /> {t("voteElection.endTime", { endTime: batch.endTime && format(batch.endTime, TIME_FORMAT), })} </Badge> <Button isSmall variant="secondary" className={cn( "max-h-8 p-1.5 responsive-text-sm", getBadgeColorByStatus(batch.votingStatus) )} > {batch.votingStatus == "CHOBOPHIEU" ? t("voteElection.status.waiting") : batch.votingStatus == "DABOPHIEU" ? t("voteElection.status.voted") : t("voteElection.status.closed")} </Button> </div> ); }; const VotingContent = ({ onOpenChange, isLoading, data, onShowResultDialog, invalidateBatchesQuery, }: { onOpenChange: (open: boolean) => void; isLoading: boolean; data: ApiResponse<BatchIssuesResponse | null>; onShowResultDialog: (open: { open: boolean; content: string }) => void; invalidateBatchesQuery: RefetchProps; }) => { const { sendOtpVerify } = useAuthApi(); const { showError } = useError(); const { t, i18n } = useTranslation(["overview"]); const [showOtpDialog, setShowOtpDialog] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false); const language = i18n.language as Language; const form = useForm<VotingFormValues>({ resolver: zodResolver(votingSchema), defaultValues: { batchVote: VOTE_OPTIONS.NONE, individualVotes: {}, }, }); const isVerifyOtp = data?.data?.isRequestOtp; const isDisabled = !data?.data?.isAllowedToVote; const handleOpenDialog = () => { setShowOtpDialog(true); setIsDialogOpen(true); }; const handleCloseDialog = (open: boolean) => { setIsDialogOpen(open); }; // Side effect to initialize form values when data changes useEffect(() => { if (data?.data?.votingIssues) { const initialVotes = data.data.votingIssues.reduce< Record<string, VoteOption> >((acc, cur) => { acc[cur.issueId.toString()] = cur.eVote; return acc; }, {}); // Determine initial batch vote const votes = Object.values(initialVotes); const initialBatchVote = votes.length > 0 && votes.every(vote => vote === votes[0] && vote !== VOTE_OPTIONS.NONE) ? votes[0] : VOTE_OPTIONS.NONE; // Update form values form.setValue("individualVotes", initialVotes); form.setValue("batchVote", initialBatchVote); } }, [data?.data?.votingIssues, form]); const addVoteIssuesMutation = useMutation(voteIssuesOptions()); const [dialog, setDialog] = useState<DialogType>({ open: false, isSuccess: true, message: "", type: "none", }); const handleSubmit = async (params: VotingFormValues) => { // Handle voting form submission console.log("Voting data:", params); if (isVerifyOtp) { sendOtpVerify.mutate(undefined, { onSuccess: function ({ statusCode, ticketId, errorInfo }) { if (isHttpStatusSuccess(statusCode)) { handleOpenDialog(); } else { showError( createErrorWithFallback( { errorInfo, statusCode, }, { details: ticketId || undefined, showErrorCode: true } ) ); } }, }); return; } mutationForm(); }; const onSubmit = form.handleSubmit(handleSubmit); const mutationForm = async (otp?: string) => { if (!data.data) return; const { errorInfo, statusCode, ticketId } = await addVoteIssuesMutation.mutateAsync({ batchId: data.data.batchId, shareQtyConfirm: data.data.shareQtyConfirm, issues: Object.entries(form.getValues().individualVotes).map( ([issueId, vote]) => ({ issueId: Number(issueId), eVote: vote, }) ), otp, }); if (!errorInfo && isHttpStatusSuccess(statusCode)) { await invalidateBatchesQuery(); onShowResultDialog({ open: true, content: t("voteElection.vote.success"), }); handleCloseDialog(false); onOpenChange(false); } else { showError( createErrorWithFallback( { errorInfo, statusCode, }, { details: ticketId || undefined, showErrorCode: true } ) ); } }; // Watch for batchVote changes and update all individual votes const batchVote = useWatch({ control: form.control, name: "batchVote" }); useEffect(() => { // Only update individual votes when batchVote has a valid value (not empty string) if (batchVote !== VOTE_OPTIONS.NONE && batchVote !== undefined) { const newIndividualVotes = data.data?.votingIssues?.reduce( (acc, item) => { acc[item.issueId.toString()] = batchVote; return acc; }, {} as Record<string, VoteOption> ); if (newIndividualVotes) { form.setValue("individualVotes", newIndividualVotes); } } }, [batchVote, form]); // Handle form errors useEffect(() => { if (Object.keys(form.formState.errors).length > 0) { // Handle errors here const errors = Object.values(form.formState.errors); const message = errors.map((err, i) => { const code = err.message?.toString() || err.root?.message; return ( <p key={i}> {errors.length > 1 && "• "} {t(code)} </p> ); }); setDialog({ open: true, message, isSuccess: false, type: "error" }); } }, [form.formState.errors]); return ( <Form {...form}> <div className="px-4 py-3.5"> <div className="flex flex-col gap-3"> {/* Bỏ phiếu hàng loạt */} <div className="grid grid-cols-1 gap-y-2 xs:grid-cols-3 sm:grid-cols-2 px-2.5"> <h2 className="responsive-text text-primary font-semibold text-left"> {t("voteElection.vote.title")} </h2> <FormField disabled={isDisabled} control={form.control} name="batchVote" render={({ field }) => ( <FormItem className="xs:col-span-2 sm:col-span-1"> <FormControl> <RadioGroup value={field.value} onValueChange={field.onChange} className="grid grid-cols-3 gap-5 justify-items-start xs:justify-items-end" > <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.AGREE} id="batch-approve" /> <Label htmlFor="batch-approve" className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.agree")} </Label> </div> <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.DISAGREE} id="batch-disapprove" /> <Label htmlFor="batch-disapprove" className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.disagree")} </Label> </div> <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.NOIDEA} id="batch-abstain" /> <Label htmlFor="batch-abstain" className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.noOption")} </Label> </div> </RadioGroup> </FormControl> </FormItem> )} /> </div> {/* Bỏ phiếu từng biểu quyết */} {!isLoading ? ( data?.data ? ( <ScrollArea className="rounded-default"> {data.data.votingIssues ? ( data.data.votingIssues?.map((item, index) => ( <VotingItem key={item.issueId} item={item} isDisabled={isDisabled} length={data.data?.votingIssues?.length || 0} index={index} form={form} /> )) ) : ( <NoContent /> )} </ScrollArea> ) : ( <ErrorContent errorInfo={data?.errorInfo} className="m-4" /> ) ) : ( <div className="relative min-h-[150px]"> <Skeleton className="h-full rounded-default" /> <LoadingOverlay visible={isLoading} /> </div> )} </div> </div> <VotingElectionFooter onSubmit={onSubmit} isDisabled={isDisabled || isDialogOpen} batchId={data?.data?.batchId} batchName={ language === "vi" ? data?.data?.batchName : data?.data?.batchNameEn } batchType={data?.data?.batchType} /> <CommonDialog className="md:max-w-sm!" open={dialog.open} onOpenChange={(open: boolean) => { if (!open && !dialog.isSuccess) form.clearErrors(); if (!open && dialog.isSuccess && dialog.type !== "none") onOpenChange(false); setDialog({ ...dialog, open }); }} content={dialog.message} iconType={dialog.isSuccess ? "success" : "error"} /> {showOtpDialog && ( <Suspense fallback={<LoadingOverlay visible={true} />}> <LazyOtpVerificationDialog open={isDialogOpen} onOpenChange={handleCloseDialog} onConfirm={mutationForm} /> </Suspense> )} </Form> ); }; const ElectionContent = ({ onOpenChange, data, isLoading, onShowResultDialog, invalidateBatchesQuery, }: { onOpenChange: (open: boolean) => void; data: ApiResponse<BatchCandidatesResponse | null>; isLoading: boolean; onShowResultDialog: (open: { open: boolean; content: string }) => void; invalidateBatchesQuery: RefetchProps; }) => { const { sendOtpVerify } = useAuthApi(); const { showError } = useError(); const { t, i18n } = useTranslation(["overview"]); const [showOtpDialog, setShowOtpDialog] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false); const language = i18n.language as Language; const isVerifyOtp = data?.data?.isRequestOtp; // Safe access to data with loading check const totalLimit = (!isLoading && data?.data?.candidateShareholder?.totalVotes) || 0; const isDisabled = !data?.data?.isAllowedToVote; // Create dynamic schema with the total limit from API const dynamicElectionSchema = createElectionSchema(totalLimit); const form = useForm<ElectionFormValues>({ resolver: zodResolver(dynamicElectionSchema), defaultValues: { candidates: {}, }, }); const handleOpenDialog = () => { setShowOtpDialog(true); setIsDialogOpen(true); }; const handleCloseDialog = (open: boolean) => { setIsDialogOpen(open); }; // Side effect to initialize form values when data changes useEffect(() => { if (!isLoading && data?.data?.votingCandidates) { const initialValues: Record<string, number> = {}; data.data.votingCandidates.forEach(candidate => { initialValues[candidate.candidateId.toString()] = candidate.ballotCounter || 0; }); form.setValue("candidates", initialValues); } }, [data?.data?.votingCandidates, isLoading]); const addVoteCandidatesMutation = useMutation(voteCandidatesOptions()); const [dialog, setDialog] = useState<DialogType>({ open: false, isSuccess: true, message: "", type: "none", }); const isMobileScreen = useMediaQuery({ maxWidth: 480 }); // Watch candidates form values and calculate total voted dynamically using useWatch (form.watch doesn't work) const watchedCandidates = useWatch({ control: form.control, name: "candidates", }) as Record<string, number> | undefined; const totalVoted = Object.values(watchedCandidates || {}).reduce( (sum, v) => sum + (typeof v === "number" ? v : Number(v || 0)), 0 ); const handleSubmit = async (data: ElectionFormValues) => { // Handle election form submission console.log("Election data:", data); if (totalVoted <= 0) { setDialog({ message: t("voteElection.election.error.minVote"), open: true, type: "error", isSuccess: false, }); return; } if (totalVoted < totalLimit) { setDialog({ message: t("voteElection.election.error.remainVote", { remaining: totalLimit - totalVoted, }), open: true, type: "none", isSuccess: true, }); return; } handleVerifyOtpBeforeSubmit(); }; const handleVerifyOtpBeforeSubmit = () => { if (isVerifyOtp) { sendOtpVerify.mutate(undefined, { onSuccess: function ({ statusCode, ticketId, errorInfo }) { if (isHttpStatusSuccess(statusCode)) { handleOpenDialog(); } else { showError( createErrorWithFallback( { errorInfo, statusCode, }, { details: ticketId || undefined, showErrorCode: true } ) ); } }, }); return; } mutationForm(); }; const onSubmit = form.handleSubmit(handleSubmit); const mutationForm = async (otp?: string) => { if (!data.data) return; const formData = form.getValues(); const { errorInfo, statusCode, ticketId } = await addVoteCandidatesMutation.mutateAsync({ batchId: data.data.batchId, batchType: data.data.batchType, candidates: Object.entries(formData.candidates).map( ([candidateId, ballotCounter]) => ({ candidateId: Number(candidateId), ballotCounter, }) ), otp, }); if (!errorInfo && isHttpStatusSuccess(statusCode)) { await invalidateBatchesQuery(); onShowResultDialog({ open: true, content: t("voteElection.election.success"), }); handleCloseDialog(false); onOpenChange(false); } else { showError( createErrorWithFallback( { errorInfo, statusCode, }, { details: ticketId || undefined, showErrorCode: true } ) ); } }; const candidates = (!isLoading && data?.data?.votingCandidates) || []; const [setCandidate, { add, has, remove, reset }] = useSet(new Set<number>()); // Initialize checkboxes for candidates with existing votes (ballotCounter > 0) useUpdateEffect(() => { if (!isLoading && candidates.length > 0) { // Clear existing selections first reset(); // Calculate candidates with votes > 0 const candidatesWithVotes = candidates.filter( candidate => candidate.ballotCounter > 0 ); if (candidatesWithVotes.length > 0) { // Calculate average votes per candidate with votes const avgVotesPerCandidate = Math.floor( totalLimit / candidatesWithVotes.length ); // Only check candidates whose ballotCounter equals the average candidates.forEach(candidate => { if (candidate.ballotCounter === avgVotesPerCandidate) { add(candidate.candidateId); } }); } // Initialize form values with existing ballotCounter values (preserve actual data) const formValues: Record<string, number> = {}; candidates.forEach(candidate => { formValues[candidate.candidateId.toString()] = candidate.ballotCounter || 0; }); form.setValue("candidates", formValues); } }, [candidates, isLoading, totalLimit]); // Handle manual checkbox changes by user interaction const handleCheckboxChange = (candidateId: number, checked: boolean) => { // compute previous size before mutating the set to avoid off-by-one const prevSize = setCandidate.size; if (checked) { add(candidateId); } else { remove(candidateId); } // Calculate new selection set size after the change const newSelectionSize = checked ? prevSize + 1 : Math.max(0, prevSize - 1); if (newSelectionSize > 0) { const avgVotes = Math.floor(totalLimit / newSelectionSize); const formValues: Record<string, number> = {}; candidates.forEach(candidate => { const willBeSelected = candidate.candidateId === candidateId ? checked : has(candidate.candidateId); formValues[candidate.candidateId.toString()] = willBeSelected ? avgVotes : 0; }); form.setValue("candidates", formValues); } else { // Reset all values to 0 when no candidates are selected const formValues: Record<string, number> = {}; candidates.forEach(candidate => { formValues[candidate.candidateId.toString()] = 0; }); form.setValue("candidates", formValues); } }; return ( <Form {...form}> {!isLoading ? ( data?.data ? ( <div className="flex flex-col gap-3.5 px-4 pt-2.5 pb-3"> <div className="flex flex-col gap-2"> <h2 className="responsive-text text-primary font-semibold text-left"> {t("voteElection.election.numElectionInfo")} </h2> <div className="flex max-sm:overflow-x-auto max-sm:overflow-y-hidden gap-3 pb-1"> <div className="max-sm:flex-shrink-0 max-w-40 xs:max-w-52 sm:max-w-64 lg:max-w-none"> <NumberOfShares title={t("shares.shareQtyConfirm")} count={ data?.data.candidateShareholder?.shareQtyConfirm || 0 } /> </div> <div className="max-sm:flex-shrink-0 max-w-40 xs:max-w-52 sm:max-w-64 lg:max-w-none"> <NumberOfShares title={t("shares.candidateCounter")} count={ data?.data.candidateShareholder?.candidateCounter || 0 } /> </div> <div className="max-sm:flex-shrink-0 max-w-40 xs:max-w-52 sm:max-w-64 lg:max-w-none"> <NumberOfShares title={t("shares.totalVotes")} count={data?.data.candidateShareholder?.totalVotes || 0} /> </div> </div> </div> <div className="flex flex-col gap-2 mb-4 rounded-lg"> <h2 className="responsive-text text-primary font-semibold text-left"> {t("voteElection.election.infoCandidate")} </h2> <Table className="text-left"> <TableHeader> <TableRow> <TableHead> {t("voteElection.election.table.candidateName", { tag: isMobileScreen ? <br key="br-1" /> : "", })} </TableHead> <TableHead className="text-center cursor-pointer" onClick={() => { if (setCandidate.size) { // Clear selection and reset form values reset(); form.reset(); } else { // Select all and distribute average votes candidates.forEach(i => add(i.candidateId)); const avgVotes = Math.floor( totalLimit / Math.max(1, candidates.length) ); const values: Record<string, number> = {}; candidates.forEach(c => { values[c.candidateId.toString()] = avgVotes; }); form.setValue("candidates", values); } }} > {t("voteElection.election.table.cumulativeVoting")}{" "} {isMobileScreen && <br key="br-2" />} </TableHead> <TableHead> {t("voteElection.election.table.numberVotes")} </TableHead> </TableRow> </TableHeader> <TableBody> {candidates.map(i => ( <TableRow key={i.candidateId}> <TableCell>{i.fullName}</TableCell> <TableCell className="text-center"> <Checkbox disabled={isDisabled} onCheckedChange={checked => { handleCheckboxChange( i.candidateId, checked === true ); }} checked={has(i.candidateId)} /> </TableCell> <TableCell> <FormField control={form.control} name={`candidates.${i.candidateId}`} render={({ field }) => ( <FormItem> <FormControl> <NumericFormat customInput={Input} placeholder={t( "voteElection.election.table.placeholder" )} className="px-3! py-1.5! min-w-28" disabled={!!setCandidate.size || isDisabled} value={field.value || ""} onValueChange={values => { const { floatValue } = values; field.onChange(floatValue || 0); }} thousandSeparator="," allowNegative={false} decimalScale={0} isAllowed={values => { const { floatValue } = values; return ( floatValue === undefined || floatValue >= 0 ); }} /> </FormControl> </FormItem> )} /> </TableCell> </TableRow> ))} </TableBody> <TableFooter> <TableRow> <TableCell className="text-foreground-05" colSpan={2}> {t("voteElection.election.table.totalVotes")} </TableCell> <TableCell className="text-foreground-05"> {totalVoted} </TableCell> </TableRow> </TableFooter> </Table> </div> </div> ) : ( <ErrorContent errorInfo={data?.errorInfo} className="m-4" /> ) ) : ( <div className="relative min-h-[400px]"> <Skeleton className="h-full rounded-default" /> <LoadingOverlay visible={isLoading} /> </div> )} <VotingElectionFooter onSubmit={onSubmit} isDisabled={isDisabled || isDialogOpen} batchId={data?.data?.batchId} batchName={ language === "vi" ? data?.data?.batchName : data?.data?.batchNameEn } batchType={data?.data?.batchType} /> {showOtpDialog && ( <Suspense fallback={<LoadingOverlay visible={true} />}> <LazyOtpVerificationDialog open={isDialogOpen} onOpenChange={handleCloseDialog} onConfirm={mutationForm} /> </Suspense> )} <CommonDialog open={dialog.open} onOpenChange={(open: boolean) => { if (!open && !dialog.isSuccess) form.clearErrors(); if (!open && dialog.isSuccess && dialog.type !== "none") onOpenChange(false); setDialog({ ...dialog, open }); }} rightButton={ dialog.isSuccess && dialog.type === "none" ? ( <Button className="flex-1" onClick={handleVerifyOtpBeforeSubmit}> {t("voteElection.confirm")} </Button> ) : null } content={dialog.message} iconType={dialog.type} className="md:max-w-sm!" /> </Form> ); }; const VotingItem = ({ item, index, form, length, isDisabled, }: { item: IssuesVoteDTO; index: number; form: UseFormReturn<VotingFormValues>; length: number; isDisabled: boolean; }) => { const { i18n, t } = useTranslation(["overview"]); const language = i18n.language as Language; const { content, description } = getLocalizedProperties( item, ["content", "description"], language ); return ( <div className={cn( "grid grid-cols-1 sm:grid-cols-2 gap-y-2 p-2.5 rounded-none bg-background-06", index < length - 1 && "border-b-2 border-background" )} > <div className="text-foreground-05 text-left flex flex-col gap-1.5"> <h3 className="responsive-text font-semibold">{content}</h3> <p className="responsive-text-sm">{description}</p> </div> <FormField disabled={isDisabled} control={form.control} name={`individualVotes.${item.issueId}`} render={({ field }) => ( <FormItem> <FormControl> <RadioGroup value={field.value} onValueChange={value => { // Clear batch vote when individual vote is changed form.setValue("batchVote", VOTE_OPTIONS.NONE); field.onChange(value); }} className="grid grid-cols-3 gap-5 justify-items-start sm:justify-items-end content-start" > <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.AGREE} id={`approve-${item.issueId}`} /> <Label htmlFor={`approve-${item.issueId}`} className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.agree")} </Label> </div> <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.DISAGREE} id={`disapprove-${item.issueId}`} /> <Label htmlFor={`disapprove-${item.issueId}`} className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.disagree")} </Label> </div> <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.NOIDEA} id={`abstain-${item.issueId}`} /> <Label htmlFor={`abstain-${item.issueId}`} className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.noOption")} </Label> </div> </RadioGroup> </FormControl> </FormItem> )} /> </div> ); }; const VotingElectionFooter = ({ onSubmit, isDisabled, batchId, batchName, batchType, }: { onSubmit?: ( e?: BaseSyntheticEvent<object, any, any> | undefined ) => Promise<void>; isDisabled: boolean; batchId?: string; batchName?: string; batchType?: BatchType; }) => { const form = useFormContext(); const [isLoading, setIsLoading] = useState(false); const { showError } = useError(); const isSubmitting = form.formState.isSubmitting; const { t } = useTranslation(["overview"]); const { i18n } = useTranslation(); const language = i18n.language as Language; const queryClient = useQueryClient(); const [dialog, setDialog] = useState<{ open: boolean; data: MeetingInfoDTO[]; }>({ data: [], open: false, }); const handleFetchGuide = async () => { if (!batchId) return alert("Missing batchId"); const data = await queryClient.fetchQuery( informationQueryOptions(INFO_TYPES.TAILIEU, batchId) ); setDialog({ open: true, data: data.data || [] }); }; const handleDownloadPdf = async (batchId?: string) => { try { if (!batchId) { alert("Missing batchId"); return; } setIsLoading(true); const isVoting = batchType === "BIEUQUYET"; const blobData = await queryClient.fetchQuery( isVoting ? downloadVotingPdf(batchId) : downloadElectionPdf(batchId) ); // The API returns Uint8Array for non-JSON responses (PDFs, etc.) const blob = new Blob([blobData], { type: "application/pdf", }); const blobUrl = URL.createObjectURL(blob); // Create a temporary anchor element to trigger download const link = document.createElement("a"); link.href = blobUrl; const linkName = language === "vi" ? isVoting ? "Giấy biểu quyết" : "Giấy bầu cử" : isVoting ? "Voting form" : "Election form"; link.download = `${linkName} ${batchName}.pdf`; document.body.appendChild(link); link.click(); // Cleanup document.body.removeChild(link); URL.revokeObjectURL(blobUrl); } catch (error) { showError( createErrorWithFallback( error as { errorInfo?: { errorCode?: string; errorMessageVI?: string; errorMessageEN?: string; }; statusCode?: number; }, { showErrorCode: true, } ) ); } finally { setIsLoading(false); } }; return ( <div className="flex flex-col xs:flex-row gap-x-6 gap-y-3 px-4 pb-3"> <div className="flex gap-3 items-center"> <Button type="button" variant="secondary" className="max-h-8 xs:max-h-[unset]" disabled={isLoading} onClick={() => handleDownloadPdf(batchId)} > {!isLoading ? <DownloadIcon /> : <Loader className="animate-spin" />} {t("voteElection.downloadBallot")} </Button> <Button type="button" variant="secondary" className="max-h-8 xs:max-h-[unset]" onClick={handleFetchGuide} > <DocumentIcon className="[&>path]:stroke-primary" /> {t("voteElection.manual")} </Button> </div> {onSubmit && ( <Button type="submit" onClick={onSubmit} className="xs:flex-1" disabled={isDisabled || isSubmitting} > {isSubmitting ? ( <div className="flex items-center justify-center gap-2"> <Loader className="animate-spin flex-1" /> <span>{t("voteElection.confirm") + "..."}</span> </div> ) : ( t("voteElection.confirm") )} </Button> )} <LazyMeetingDocumentDialog open={dialog.open} onOpenChange={open => { if (!open) setDialog({ ...dialog, open }); }} meetingInfos={dialog.data} /> </div> ); }; export default VotingElectionDialog;243214import CommonDialog, { type IconType } from "@/components/dialog"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { Form, FormControl, FormField, FormItem } from "@/components/ui/form"; import { Drawer, DrawerClose, DrawerContent, DrawerHeader, DrawerTitle, } from "@/components/ui/drawer"; import DocumentIcon from "@/icons/document-icon"; import DownloadIcon from "@/icons/download-icon"; import HourglassEagerIcon from "@/icons/hourglass-eager-icon"; import { cn } from "@/lib/utils"; import { useForm, useFormContext, useWatch, type UseFormReturn, } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import type { BaseSyntheticEvent } from "react"; import { lazy, Suspense, useEffect, useState } from "react"; import NumberOfShares from "./number-of-shares"; import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { useSet, useUpdateEffect } from "react-use"; import { ScrollArea } from "@/components/ui/scroll-area"; import { NumericFormat } from "react-number-format"; import type { BatchCandidatesResponse, BatchIssuesResponse, IssuesBatchResponse, IssuesVoteDTO, MeetingInfoDTO, } from "@/types/information.type"; import { BATCH_TYPES, INFO_TYPES, VOTE_OPTIONS, type BatchType, type VoteOption, type VoteStatus, } from "@/constants/params"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { candidatesByBatchIdQueryOptions, informationQueryOptions, issuesByBatchIdQueryOptions, } from "@/features/information/information.api"; import { getLocalizedProperties, isHttpStatusSuccess } from "@/lib/helper"; import { useTranslation } from "react-i18next"; import type { Language } from "@/constants/language"; import { format } from "date-fns"; import { TIME_FORMAT } from "@/constants/format"; import type { ApiResponse } from "@/types/api.type"; import { Skeleton } from "@/components/ui/skeleton"; import { LoadingOverlay } from "@/components/ui/loading-overlay"; import { useMediaQuery } from "react-responsive"; import CancelIcon from "@/icons/cancel-icon"; import NoContent from "@/components/no-content"; import ErrorContent from "@/components/error-content"; import { downloadElectionPdf, downloadVotingPdf, voteCandidatesOptions, voteIssuesOptions, } from "@/features/meeting/meeting.api"; import { useError } from "@/contexts/error-provider"; import { createErrorWithFallback } from "@/lib/error-utils"; import type { RefetchProps } from "../$meetId/-components/batch-list"; import useAuthApi from "@/features/auth/auth.api"; import { Loader } from "lucide-react"; const LazyMeetingDocumentDialog = lazy( () => import("@/components/meeting-document-dialog") ); const LazyOtpVerificationDialog = lazy(() => import("@/components/otp-dialog")); interface IProps { open: boolean; onOpenChange: (open: boolean) => void; batch: IssuesBatchResponse; onShowResultDialog: (dialog: { open: boolean; content: string }) => void; refetchBatches: RefetchProps; } type DialogType = { open: boolean; isSuccess: boolean; message: React.ReactNode; type: IconType; }; export function getBadgeColorByStatus(status: VoteStatus) { switch (status) { case "CHOBOPHIEU": return "bg-primary-03 text-white"; case "DABOPHIEU": return "bg-primary text-white"; default: return "bg-background-07 text-foreground-09 [&_svg_path]:!fill-[#c5c5c5] [&_svg_path]:!stroke-[#c5c5c5]"; } } // Function to create election schema with dynamic total limit const createElectionSchema = (totalLimit: number) => z .object({ candidates: z.record( z.string(), z.number().min(0, "voteElection.election.error.minVote") ), }) .refine( data => { const total = Object.values(data.candidates).reduce( (sum, votes) => sum + votes, 0 ); return total <= totalLimit; }, { message: "voteElection.election.error.maxVote", path: ["candidates"], } ); type ElectionFormValues = z.infer<ReturnType<typeof createElectionSchema>>; // Form schema for voting const votingSchema = z.object({ batchVote: z .enum([ VOTE_OPTIONS.AGREE, VOTE_OPTIONS.DISAGREE, VOTE_OPTIONS.NOIDEA, VOTE_OPTIONS.NONE, VOTE_OPTIONS.INVALID, ]) .optional(), individualVotes: z .record( z.string(), z.enum([ VOTE_OPTIONS.AGREE, VOTE_OPTIONS.DISAGREE, VOTE_OPTIONS.NOIDEA, VOTE_OPTIONS.NONE, VOTE_OPTIONS.INVALID, ]) ) .refine( record => { const selectedValues = Object.values(record); // Return expected result return selectedValues.every(value => value !== VOTE_OPTIONS.NONE); }, { error: "voteElection.vote.error" } ), }); type VotingFormValues = z.infer<typeof votingSchema>; const VotingElectionDialog = ({ open, onOpenChange, batch, onShowResultDialog, refetchBatches, }: IProps) => { const isElection = batch.batchType !== BATCH_TYPES.BIEUQUYET; const isMobile = useMediaQuery({ maxWidth: 768 }); // Use separate queries for different batch types - only run when dialog is actually open const issuesOptions = issuesByBatchIdQueryOptions(batch.batchId); const issuesQuery = useQuery({ ...issuesOptions, enabled: !isElection && open, queryKey: [...issuesOptions.queryKey, String(open)], // Refetch when dialog opens refetchOnMount: false, }); const candidatesOptions = candidatesByBatchIdQueryOptions(batch.batchId); const candidatesQuery = useQuery({ ...candidatesOptions, enabled: isElection && open, queryKey: [...candidatesOptions.queryKey, String(open)], // Refetch when dialog opens refetchOnMount: false, }); const { i18n } = useTranslation(); const language = i18n.language as Language; const data = isElection ? candidatesQuery.data : issuesQuery.data; const isLoading = isElection ? candidatesQuery.isLoading : issuesQuery.isLoading; const { batchName } = getLocalizedProperties(batch, ["batchName"], language); const contentComponent = !isElection ? ( <VotingContent onOpenChange={onOpenChange} isLoading={isLoading} data={data as ApiResponse<BatchIssuesResponse | null>} onShowResultDialog={onShowResultDialog} invalidateBatchesQuery={refetchBatches} /> ) : ( <ElectionContent onOpenChange={onOpenChange} data={data as ApiResponse<BatchCandidatesResponse | null>} isLoading={isLoading} onShowResultDialog={onShowResultDialog} invalidateBatchesQuery={refetchBatches} /> ); const headerComponent = <VotingElectionHeader batch={batch} />; if (isMobile) { return ( <Drawer open={open} onOpenChange={onOpenChange}> <DrawerContent className="max-h-[90vh]!"> <DrawerHeader className="border-b-2 border-background-02 gap-2"> <div className="flex items-center justify-between"> <DrawerTitle className="text-left text-foreground-12 font-bold responsive-text-3xl"> {batchName} </DrawerTitle> <DrawerClose asChild> <Button variant="ghost" className="size-6 p-0!"> <CancelIcon className="size-6" /> <span className="sr-only">Close</span> </Button> </DrawerClose> </div> {headerComponent} </DrawerHeader> <div className="flex-1 overflow-auto">{contentComponent}</div> </DrawerContent> </Drawer> ); } return ( <CommonDialog noTrans title={batchName} content={contentComponent} open={open} className={cn( "max-w-[360px] xs:max-w-[600px] sm:max-w-[800px] gap-0", "p-0! [&>[data-slot='dialog-header']]:p-4 [&>[data-slot='dialog-header']]:border-b-2 [&>[data-slot='dialog-header']]:border-background-02 [&>[data-slot='dialog-close']]:top-4 [&>[data-slot='dialog-close']]:right-4" )} showCloseBtn={false} onOpenChange={onOpenChange} header={headerComponent} /> ); }; const VotingElectionHeader = ({ batch }: { batch: IssuesBatchResponse }) => { const { t } = useTranslation(["overview"]); return ( <div className="flex items-stretch justify-between"> <Badge variant="outlineDestructive" className="h-[unset]"> <HourglassEagerIcon className="!size-4" /> {t("voteElection.endTime", { endTime: batch.endTime && format(batch.endTime, TIME_FORMAT), })} </Badge> <Button isSmall variant="secondary" className={cn( "max-h-8 p-1.5 responsive-text-sm", getBadgeColorByStatus(batch.votingStatus) )} > {batch.votingStatus == "CHOBOPHIEU" ? t("voteElection.status.waiting") : batch.votingStatus == "DABOPHIEU" ? t("voteElection.status.voted") : t("voteElection.status.closed")} </Button> </div> ); }; const VotingContent = ({ onOpenChange, isLoading, data, onShowResultDialog, invalidateBatchesQuery, }: { onOpenChange: (open: boolean) => void; isLoading: boolean; data: ApiResponse<BatchIssuesResponse | null>; onShowResultDialog: (open: { open: boolean; content: string }) => void; invalidateBatchesQuery: RefetchProps; }) => { const { sendOtpVerify } = useAuthApi(); const { showError } = useError(); const { t, i18n } = useTranslation(["overview"]); const [showOtpDialog, setShowOtpDialog] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false); const language = i18n.language as Language; const form = useForm<VotingFormValues>({ resolver: zodResolver(votingSchema), defaultValues: { batchVote: VOTE_OPTIONS.NONE, individualVotes: {}, }, }); const isVerifyOtp = data?.data?.isRequestOtp; const isDisabled = !data?.data?.isAllowedToVote; const handleOpenDialog = () => { setShowOtpDialog(true); setIsDialogOpen(true); }; const handleCloseDialog = (open: boolean) => { setIsDialogOpen(open); }; // Side effect to initialize form values when data changes useEffect(() => { if (data?.data?.votingIssues) { const initialVotes = data.data.votingIssues.reduce< Record<string, VoteOption> >((acc, cur) => { acc[cur.issueId.toString()] = cur.eVote; return acc; }, {}); // Determine initial batch vote const votes = Object.values(initialVotes); const initialBatchVote = votes.length > 0 && votes.every(vote => vote === votes[0] && vote !== VOTE_OPTIONS.NONE) ? votes[0] : VOTE_OPTIONS.NONE; // Update form values form.setValue("individualVotes", initialVotes); form.setValue("batchVote", initialBatchVote); } }, [data?.data?.votingIssues, form]); const addVoteIssuesMutation = useMutation(voteIssuesOptions()); const [dialog, setDialog] = useState<DialogType>({ open: false, isSuccess: true, message: "", type: "none", }); const handleSubmit = async (params: VotingFormValues) => { // Handle voting form submission console.log("Voting data:", params); if (isVerifyOtp) { sendOtpVerify.mutate(undefined, { onSuccess: function ({ statusCode, ticketId, errorInfo }) { if (isHttpStatusSuccess(statusCode)) { handleOpenDialog(); } else { showError( createErrorWithFallback( { errorInfo, statusCode, }, { details: ticketId || undefined, showErrorCode: true } ) ); } }, }); return; } mutationForm(); }; const onSubmit = form.handleSubmit(handleSubmit); const mutationForm = async (otp?: string) => { if (!data.data) return; const { errorInfo, statusCode, ticketId } = await addVoteIssuesMutation.mutateAsync({ batchId: data.data.batchId, shareQtyConfirm: data.data.shareQtyConfirm, issues: Object.entries(form.getValues().individualVotes).map( ([issueId, vote]) => ({ issueId: Number(issueId), eVote: vote, }) ), otp, }); if (!errorInfo && isHttpStatusSuccess(statusCode)) { await invalidateBatchesQuery(); onShowResultDialog({ open: true, content: t("voteElection.vote.success"), }); handleCloseDialog(false); onOpenChange(false); } else { showError( createErrorWithFallback( { errorInfo, statusCode, }, { details: ticketId || undefined, showErrorCode: true } ) ); } }; // Watch for batchVote changes and update all individual votes const batchVote = useWatch({ control: form.control, name: "batchVote" }); useEffect(() => { // Only update individual votes when batchVote has a valid value (not empty string) if (batchVote !== VOTE_OPTIONS.NONE && batchVote !== undefined) { const newIndividualVotes = data.data?.votingIssues?.reduce( (acc, item) => { acc[item.issueId.toString()] = batchVote; return acc; }, {} as Record<string, VoteOption> ); if (newIndividualVotes) { form.setValue("individualVotes", newIndividualVotes); } } }, [batchVote, form]); // Handle form errors useEffect(() => { if (Object.keys(form.formState.errors).length > 0) { // Handle errors here const errors = Object.values(form.formState.errors); const message = errors.map((err, i) => { const code = err.message?.toString() || err.root?.message; return ( <p key={i}> {errors.length > 1 && "• "} {t(code)} </p> ); }); setDialog({ open: true, message, isSuccess: false, type: "error" }); } }, [form.formState.errors]); return ( <Form {...form}> <div className="px-4 py-3.5"> <div className="flex flex-col gap-3"> {/* Bỏ phiếu hàng loạt */} <div className="grid grid-cols-1 gap-y-2 xs:grid-cols-3 sm:grid-cols-2 px-2.5"> <h2 className="responsive-text text-primary font-semibold text-left"> {t("voteElection.vote.title")} </h2> <FormField disabled={isDisabled} control={form.control} name="batchVote" render={({ field }) => ( <FormItem className="xs:col-span-2 sm:col-span-1"> <FormControl> <RadioGroup value={field.value} onValueChange={field.onChange} className="grid grid-cols-3 gap-5 justify-items-start xs:justify-items-end" > <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.AGREE} id="batch-approve" /> <Label htmlFor="batch-approve" className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.agree")} </Label> </div> <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.DISAGREE} id="batch-disapprove" /> <Label htmlFor="batch-disapprove" className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.disagree")} </Label> </div> <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.NOIDEA} id="batch-abstain" /> <Label htmlFor="batch-abstain" className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.noOption")} </Label> </div> </RadioGroup> </FormControl> </FormItem> )} /> </div> {/* Bỏ phiếu từng biểu quyết */} {!isLoading ? ( data?.data ? ( <ScrollArea className="rounded-default"> {data.data.votingIssues ? ( data.data.votingIssues?.map((item, index) => ( <VotingItem key={item.issueId} item={item} isDisabled={isDisabled} length={data.data?.votingIssues?.length || 0} index={index} form={form} /> )) ) : ( <NoContent /> )} </ScrollArea> ) : ( <ErrorContent errorInfo={data?.errorInfo} className="m-4" /> ) ) : ( <div className="relative min-h-[150px]"> <Skeleton className="h-full rounded-default" /> <LoadingOverlay visible={isLoading} /> </div> )} </div> </div> <VotingElectionFooter onSubmit={onSubmit} isDisabled={isDisabled || isDialogOpen} batchId={data?.data?.batchId} batchName={ language === "vi" ? data?.data?.batchName : data?.data?.batchNameEn } batchType={data?.data?.batchType} /> <CommonDialog className="md:max-w-sm!" open={dialog.open} onOpenChange={(open: boolean) => { if (!open && !dialog.isSuccess) form.clearErrors(); if (!open && dialog.isSuccess && dialog.type !== "none") onOpenChange(false); setDialog({ ...dialog, open }); }} content={dialog.message} iconType={dialog.isSuccess ? "success" : "error"} /> {showOtpDialog && ( <Suspense fallback={<LoadingOverlay visible={true} />}> <LazyOtpVerificationDialog open={isDialogOpen} onOpenChange={handleCloseDialog} onConfirm={mutationForm} /> </Suspense> )} </Form> ); }; const ElectionContent = ({ onOpenChange, data, isLoading, onShowResultDialog, invalidateBatchesQuery, }: { onOpenChange: (open: boolean) => void; data: ApiResponse<BatchCandidatesResponse | null>; isLoading: boolean; onShowResultDialog: (open: { open: boolean; content: string }) => void; invalidateBatchesQuery: RefetchProps; }) => { const { sendOtpVerify } = useAuthApi(); const { showError } = useError(); const { t, i18n } = useTranslation(["overview"]); const [showOtpDialog, setShowOtpDialog] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false); const language = i18n.language as Language; const isVerifyOtp = data?.data?.isRequestOtp; // Safe access to data with loading check const totalLimit = (!isLoading && data?.data?.candidateShareholder?.totalVotes) || 0; const isDisabled = !data?.data?.isAllowedToVote; // Create dynamic schema with the total limit from API const dynamicElectionSchema = createElectionSchema(totalLimit); const form = useForm<ElectionFormValues>({ resolver: zodResolver(dynamicElectionSchema), defaultValues: { candidates: {}, }, }); const handleOpenDialog = () => { setShowOtpDialog(true); setIsDialogOpen(true); }; const handleCloseDialog = (open: boolean) => { setIsDialogOpen(open); }; // Side effect to initialize form values when data changes useEffect(() => { if (!isLoading && data?.data?.votingCandidates) { const initialValues: Record<string, number> = {}; data.data.votingCandidates.forEach(candidate => { initialValues[candidate.candidateId.toString()] = candidate.ballotCounter || 0; }); form.setValue("candidates", initialValues); } }, [data?.data?.votingCandidates, isLoading]); const addVoteCandidatesMutation = useMutation(voteCandidatesOptions()); const [dialog, setDialog] = useState<DialogType>({ open: false, isSuccess: true, message: "", type: "none", }); const isMobileScreen = useMediaQuery({ maxWidth: 480 }); // Watch candidates form values and calculate total voted dynamically using useWatch (form.watch doesn't work) const watchedCandidates = useWatch({ control: form.control, name: "candidates", }) as Record<string, number> | undefined; const totalVoted = Object.values(watchedCandidates || {}).reduce( (sum, v) => sum + (typeof v === "number" ? v : Number(v || 0)), 0 ); const handleSubmit = async (data: ElectionFormValues) => { // Handle election form submission console.log("Election data:", data); if (totalVoted <= 0) { setDialog({ message: t("voteElection.election.error.minVote"), open: true, type: "error", isSuccess: false, }); return; } if (totalVoted < totalLimit) { setDialog({ message: t("voteElection.election.error.remainVote", { remaining: totalLimit - totalVoted, }), open: true, type: "none", isSuccess: true, }); return; } handleVerifyOtpBeforeSubmit(); }; const handleVerifyOtpBeforeSubmit = () => { if (isVerifyOtp) { sendOtpVerify.mutate(undefined, { onSuccess: function ({ statusCode, ticketId, errorInfo }) { if (isHttpStatusSuccess(statusCode)) { handleOpenDialog(); } else { showError( createErrorWithFallback( { errorInfo, statusCode, }, { details: ticketId || undefined, showErrorCode: true } ) ); } }, }); return; } mutationForm(); }; const onSubmit = form.handleSubmit(handleSubmit); const mutationForm = async (otp?: string) => { if (!data.data) return; const formData = form.getValues(); const { errorInfo, statusCode, ticketId } = await addVoteCandidatesMutation.mutateAsync({ batchId: data.data.batchId, batchType: data.data.batchType, candidates: Object.entries(formData.candidates).map( ([candidateId, ballotCounter]) => ({ candidateId: Number(candidateId), ballotCounter, }) ), otp, }); if (!errorInfo && isHttpStatusSuccess(statusCode)) { await invalidateBatchesQuery(); onShowResultDialog({ open: true, content: t("voteElection.election.success"), }); handleCloseDialog(false); onOpenChange(false); } else { showError( createErrorWithFallback( { errorInfo, statusCode, }, { details: ticketId || undefined, showErrorCode: true } ) ); } }; const candidates = (!isLoading && data?.data?.votingCandidates) || []; const [setCandidate, { add, has, remove, reset }] = useSet(new Set<number>()); // Initialize checkboxes for candidates with existing votes (ballotCounter > 0) useUpdateEffect(() => { if (!isLoading && candidates.length > 0) { // Clear existing selections first reset(); // Calculate candidates with votes > 0 const candidatesWithVotes = candidates.filter( candidate => candidate.ballotCounter > 0 ); if (candidatesWithVotes.length > 0) { // Calculate average votes per candidate with votes const avgVotesPerCandidate = Math.floor( totalLimit / candidatesWithVotes.length ); // Only check candidates whose ballotCounter equals the average candidates.forEach(candidate => { if (candidate.ballotCounter === avgVotesPerCandidate) { add(candidate.candidateId); } }); } // Initialize form values with existing ballotCounter values (preserve actual data) const formValues: Record<string, number> = {}; candidates.forEach(candidate => { formValues[candidate.candidateId.toString()] = candidate.ballotCounter || 0; }); form.setValue("candidates", formValues); } }, [candidates, isLoading, totalLimit]); // Handle manual checkbox changes by user interaction const handleCheckboxChange = (candidateId: number, checked: boolean) => { // compute previous size before mutating the set to avoid off-by-one const prevSize = setCandidate.size; if (checked) { add(candidateId); } else { remove(candidateId); } // Calculate new selection set size after the change const newSelectionSize = checked ? prevSize + 1 : Math.max(0, prevSize - 1); if (newSelectionSize > 0) { const avgVotes = Math.floor(totalLimit / newSelectionSize); const formValues: Record<string, number> = {}; candidates.forEach(candidate => { const willBeSelected = candidate.candidateId === candidateId ? checked : has(candidate.candidateId); formValues[candidate.candidateId.toString()] = willBeSelected ? avgVotes : 0; }); form.setValue("candidates", formValues); } else { // Reset all values to 0 when no candidates are selected const formValues: Record<string, number> = {}; candidates.forEach(candidate => { formValues[candidate.candidateId.toString()] = 0; }); form.setValue("candidates", formValues); } }; return ( <Form {...form}> {!isLoading ? ( data?.data ? ( <div className="flex flex-col gap-3.5 px-4 pt-2.5 pb-3"> <div className="flex flex-col gap-2"> <h2 className="responsive-text text-primary font-semibold text-left"> {t("voteElection.election.numElectionInfo")} </h2> <div className="flex max-sm:overflow-x-auto max-sm:overflow-y-hidden gap-3 pb-1"> <div className="max-sm:flex-shrink-0 max-w-40 xs:max-w-52 sm:max-w-64 lg:max-w-none"> <NumberOfShares title={t("shares.shareQtyConfirm")} count={ data?.data.candidateShareholder?.shareQtyConfirm || 0 } /> </div> <div className="max-sm:flex-shrink-0 max-w-40 xs:max-w-52 sm:max-w-64 lg:max-w-none"> <NumberOfShares title={t("shares.candidateCounter")} count={ data?.data.candidateShareholder?.candidateCounter || 0 } /> </div> <div className="max-sm:flex-shrink-0 max-w-40 xs:max-w-52 sm:max-w-64 lg:max-w-none"> <NumberOfShares title={t("shares.totalVotes")} count={data?.data.candidateShareholder?.totalVotes || 0} /> </div> </div> </div> <div className="flex flex-col gap-2 mb-4 rounded-lg"> <h2 className="responsive-text text-primary font-semibold text-left"> {t("voteElection.election.infoCandidate")} </h2> <Table className="text-left"> <TableHeader> <TableRow> <TableHead> {t("voteElection.election.table.candidateName", { tag: isMobileScreen ? <br key="br-1" /> : "", })} </TableHead> <TableHead className="text-center cursor-pointer" onClick={() => { if (setCandidate.size) { // Clear selection and reset form values reset(); form.reset(); } else { // Select all and distribute average votes candidates.forEach(i => add(i.candidateId)); const avgVotes = Math.floor( totalLimit / Math.max(1, candidates.length) ); const values: Record<string, number> = {}; candidates.forEach(c => { values[c.candidateId.toString()] = avgVotes; }); form.setValue("candidates", values); } }} > {t("voteElection.election.table.cumulativeVoting")}{" "} {isMobileScreen && <br key="br-2" />} </TableHead> <TableHead> {t("voteElection.election.table.numberVotes")} </TableHead> </TableRow> </TableHeader> <TableBody> {candidates.map(i => ( <TableRow key={i.candidateId}> <TableCell>{i.fullName}</TableCell> <TableCell className="text-center"> <Checkbox disabled={isDisabled} onCheckedChange={checked => { handleCheckboxChange( i.candidateId, checked === true ); }} checked={has(i.candidateId)} /> </TableCell> <TableCell> <FormField control={form.control} name={`candidates.${i.candidateId}`} render={({ field }) => ( <FormItem> <FormControl> <NumericFormat customInput={Input} placeholder={t( "voteElection.election.table.placeholder" )} className="px-3! py-1.5! min-w-28" disabled={!!setCandidate.size || isDisabled} value={field.value || ""} onValueChange={values => { const { floatValue } = values; field.onChange(floatValue || 0); }} thousandSeparator="," allowNegative={false} decimalScale={0} isAllowed={values => { const { floatValue } = values; return ( floatValue === undefined || floatValue >= 0 ); }} /> </FormControl> </FormItem> )} /> </TableCell> </TableRow> ))} </TableBody> <TableFooter> <TableRow> <TableCell className="text-foreground-05" colSpan={2}> {t("voteElection.election.table.totalVotes")} </TableCell> <TableCell className="text-foreground-05"> {totalVoted} </TableCell> </TableRow> </TableFooter> </Table> </div> </div> ) : ( <ErrorContent errorInfo={data?.errorInfo} className="m-4" /> ) ) : ( <div className="relative min-h-[400px]"> <Skeleton className="h-full rounded-default" /> <LoadingOverlay visible={isLoading} /> </div> )} <VotingElectionFooter onSubmit={onSubmit} isDisabled={isDisabled || isDialogOpen} batchId={data?.data?.batchId} batchName={ language === "vi" ? data?.data?.batchName : data?.data?.batchNameEn } batchType={data?.data?.batchType} /> {showOtpDialog && ( <Suspense fallback={<LoadingOverlay visible={true} />}> <LazyOtpVerificationDialog open={isDialogOpen} onOpenChange={handleCloseDialog} onConfirm={mutationForm} /> </Suspense> )} <CommonDialog open={dialog.open} onOpenChange={(open: boolean) => { if (!open && !dialog.isSuccess) form.clearErrors(); if (!open && dialog.isSuccess && dialog.type !== "none") onOpenChange(false); setDialog({ ...dialog, open }); }} rightButton={ dialog.isSuccess && dialog.type === "none" ? ( <Button className="flex-1" onClick={handleVerifyOtpBeforeSubmit}> {t("voteElection.confirm")} </Button> ) : null } content={dialog.message} iconType={dialog.type} className="md:max-w-sm!" /> </Form> ); }; const VotingItem = ({ item, index, form, length, isDisabled, }: { item: IssuesVoteDTO; index: number; form: UseFormReturn<VotingFormValues>; length: number; isDisabled: boolean; }) => { const { i18n, t } = useTranslation(["overview"]); const language = i18n.language as Language; const { content, description } = getLocalizedProperties( item, ["content", "description"], language ); return ( <div className={cn( "grid grid-cols-1 sm:grid-cols-2 gap-y-2 p-2.5 rounded-none bg-background-06", index < length - 1 && "border-b-2 border-background" )} > <div className="text-foreground-05 text-left flex flex-col gap-1.5"> <h3 className="responsive-text font-semibold">{content}</h3> <p className="responsive-text-sm">{description}</p> </div> <FormField disabled={isDisabled} control={form.control} name={`individualVotes.${item.issueId}`} render={({ field }) => ( <FormItem> <FormControl> <RadioGroup value={field.value} onValueChange={value => { // Clear batch vote when individual vote is changed form.setValue("batchVote", VOTE_OPTIONS.NONE); field.onChange(value); }} className="grid grid-cols-3 gap-5 justify-items-start sm:justify-items-end content-start" > <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.AGREE} id={`approve-${item.issueId}`} /> <Label htmlFor={`approve-${item.issueId}`} className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.agree")} </Label> </div> <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.DISAGREE} id={`disapprove-${item.issueId}`} /> <Label htmlFor={`disapprove-${item.issueId}`} className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.disagree")} </Label> </div> <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.NOIDEA} id={`abstain-${item.issueId}`} /> <Label htmlFor={`abstain-${item.issueId}`} className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.noOption")} </Label> </div> </RadioGroup> </FormControl> </FormItem> )} /> </div> ); }; const VotingElectionFooter = ({ onSubmit, isDisabled, batchId, batchName, batchType, }: { onSubmit?: ( e?: BaseSyntheticEvent<object, any, any> | undefined ) => Promise<void>; isDisabled: boolean; batchId?: string; batchName?: string; batchType?: BatchType; }) => { const form = useFormContext(); const [isLoading, setIsLoading] = useState(false); const { showError } = useError(); const isSubmitting = form.formState.isSubmitting; const { t } = useTranslation(["overview"]); const { i18n } = useTranslation(); const language = i18n.language as Language; const queryClient = useQueryClient(); const [dialog, setDialog] = useState<{ open: boolean; data: MeetingInfoDTO[]; }>({ data: [], open: false, }); const handleFetchGuide = async () => { if (!batchId) return alert("Missing batchId"); const data = await queryClient.fetchQuery( informationQueryOptions(INFO_TYPES.TAILIEU, batchId) ); setDialog({ open: true, data: data.data || [] }); }; const handleDownloadPdf = async (batchId?: string) => { try { if (!batchId) { alert("Missing batchId"); return; } setIsLoading(true); const isVoting = batchType === "BIEUQUYET"; const blobData = await queryClient.fetchQuery( isVoting ? downloadVotingPdf(batchId) : downloadElectionPdf(batchId) ); // The API returns Uint8Array for non-JSON responses (PDFs, etc.) const blob = new Blob([blobData], { type: "application/pdf", }); const blobUrl = URL.createObjectURL(blob); // Create a temporary anchor element to trigger download const link = document.createElement("a"); link.href = blobUrl; const linkName = language === "vi" ? isVoting ? "Giấy biểu quyết" : "Giấy bầu cử" : isVoting ? "Voting form" : "Election form"; link.download = `${linkName} ${batchName}.pdf`; document.body.appendChild(link); link.click(); // Cleanup document.body.removeChild(link); URL.revokeObjectURL(blobUrl); } catch (error) { showError( createErrorWithFallback( error as { errorInfo?: { errorCode?: string; errorMessageVI?: string; errorMessageEN?: string; }; statusCode?: number; }, { showErrorCode: true, } ) ); } finally { setIsLoading(false); } }; return ( <div className="flex flex-col xs:flex-row gap-x-6 gap-y-3 px-4 pb-3"> <div className="flex gap-3 items-center"> <Button type="button" variant="secondary" className="max-h-8 xs:max-h-[unset]" disabled={isLoading} onClick={() => handleDownloadPdf(batchId)} > {!isLoading ? <DownloadIcon /> : <Loader className="animate-spin" />} {t("voteElection.downloadBallot")} </Button> <Button type="button" variant="secondary" className="max-h-8 xs:max-h-[unset]" onClick={handleFetchGuide} > <DocumentIcon className="[&>path]:stroke-primary" /> {t("voteElection.manual")} </Button> </div> {onSubmit && ( <Button type="submit" onClick={onSubmit} className="xs:flex-1" disabled={isDisabled || isSubmitting} > {isSubmitting ? ( <div className="flex items-center justify-center gap-2"> <Loader className="animate-spin flex-1" /> <span>{t("voteElection.confirm") + "..."}</span> </div> ) : ( t("voteElection.confirm") )} </Button> )} <LazyMeetingDocumentDialog open={dialog.open} onOpenChange={open => { if (!open) setDialog({ ...dialog, open }); }} meetingInfos={dialog.data} /> </div> ); }; export default VotingElectionDialog;4234324import CommonDialog, { type IconType } from "@/components/dialog"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { Form, FormControl, FormField, FormItem } from "@/components/ui/form"; import { Drawer, DrawerClose, DrawerContent, DrawerHeader, DrawerTitle, } from "@/components/ui/drawer"; import DocumentIcon from "@/icons/document-icon"; import DownloadIcon from "@/icons/download-icon"; import HourglassEagerIcon from "@/icons/hourglass-eager-icon"; import { cn } from "@/lib/utils"; import { useForm, useFormContext, useWatch, type UseFormReturn, } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import type { BaseSyntheticEvent } from "react"; import { lazy, Suspense, useEffect, useState } from "react"; import NumberOfShares from "./number-of-shares"; import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { useSet, useUpdateEffect } from "react-use"; import { ScrollArea } from "@/components/ui/scroll-area"; import { NumericFormat } from "react-number-format"; import type { BatchCandidatesResponse, BatchIssuesResponse, IssuesBatchResponse, IssuesVoteDTO, MeetingInfoDTO, } from "@/types/information.type"; import { BATCH_TYPES, INFO_TYPES, VOTE_OPTIONS, type BatchType, type VoteOption, type VoteStatus, } from "@/constants/params"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { candidatesByBatchIdQueryOptions, informationQueryOptions, issuesByBatchIdQueryOptions, } from "@/features/information/information.api"; import { getLocalizedProperties, isHttpStatusSuccess } from "@/lib/helper"; import { useTranslation } from "react-i18next"; import type { Language } from "@/constants/language"; import { format } from "date-fns"; import { TIME_FORMAT } from "@/constants/format"; import type { ApiResponse } from "@/types/api.type"; import { Skeleton } from "@/components/ui/skeleton"; import { LoadingOverlay } from "@/components/ui/loading-overlay"; import { useMediaQuery } from "react-responsive"; import CancelIcon from "@/icons/cancel-icon"; import NoContent from "@/components/no-content"; import ErrorContent from "@/components/error-content"; import { downloadElectionPdf, downloadVotingPdf, voteCandidatesOptions, voteIssuesOptions, } from "@/features/meeting/meeting.api"; import { useError } from "@/contexts/error-provider"; import { createErrorWithFallback } from "@/lib/error-utils"; import type { RefetchProps } from "../$meetId/-components/batch-list"; import useAuthApi from "@/features/auth/auth.api"; import { Loader } from "lucide-react"; const LazyMeetingDocumentDialog = lazy( () => import("@/components/meeting-document-dialog") ); const LazyOtpVerificationDialog = lazy(() => import("@/components/otp-dialog")); interface IProps { open: boolean; onOpenChange: (open: boolean) => void; batch: IssuesBatchResponse; onShowResultDialog: (dialog: { open: boolean; content: string }) => void; refetchBatches: RefetchProps; } type DialogType = { open: boolean; isSuccess: boolean; message: React.ReactNode; type: IconType; }; export function getBadgeColorByStatus(status: VoteStatus) { switch (status) { case "CHOBOPHIEU": return "bg-primary-03 text-white"; case "DABOPHIEU": return "bg-primary text-white"; default: return "bg-background-07 text-foreground-09 [&_svg_path]:!fill-[#c5c5c5] [&_svg_path]:!stroke-[#c5c5c5]"; } } // Function to create election schema with dynamic total limit const createElectionSchema = (totalLimit: number) => z .object({ candidates: z.record( z.string(), z.number().min(0, "voteElection.election.error.minVote") ), }) .refine( data => { const total = Object.values(data.candidates).reduce( (sum, votes) => sum + votes, 0 ); return total <= totalLimit; }, { message: "voteElection.election.error.maxVote", path: ["candidates"], } ); type ElectionFormValues = z.infer<ReturnType<typeof createElectionSchema>>; // Form schema for voting const votingSchema = z.object({ batchVote: z .enum([ VOTE_OPTIONS.AGREE, VOTE_OPTIONS.DISAGREE, VOTE_OPTIONS.NOIDEA, VOTE_OPTIONS.NONE, VOTE_OPTIONS.INVALID, ]) .optional(), individualVotes: z .record( z.string(), z.enum([ VOTE_OPTIONS.AGREE, VOTE_OPTIONS.DISAGREE, VOTE_OPTIONS.NOIDEA, VOTE_OPTIONS.NONE, VOTE_OPTIONS.INVALID, ]) ) .refine( record => { const selectedValues = Object.values(record); // Return expected result return selectedValues.every(value => value !== VOTE_OPTIONS.NONE); }, { error: "voteElection.vote.error" } ), }); type VotingFormValues = z.infer<typeof votingSchema>; const VotingElectionDialog = ({ open, onOpenChange, batch, onShowResultDialog, refetchBatches, }: IProps) => { const isElection = batch.batchType !== BATCH_TYPES.BIEUQUYET; const isMobile = useMediaQuery({ maxWidth: 768 }); // Use separate queries for different batch types - only run when dialog is actually open const issuesOptions = issuesByBatchIdQueryOptions(batch.batchId); const issuesQuery = useQuery({ ...issuesOptions, enabled: !isElection && open, queryKey: [...issuesOptions.queryKey, String(open)], // Refetch when dialog opens refetchOnMount: false, }); const candidatesOptions = candidatesByBatchIdQueryOptions(batch.batchId); const candidatesQuery = useQuery({ ...candidatesOptions, enabled: isElection && open, queryKey: [...candidatesOptions.queryKey, String(open)], // Refetch when dialog opens refetchOnMount: false, }); const { i18n } = useTranslation(); const language = i18n.language as Language; const data = isElection ? candidatesQuery.data : issuesQuery.data; const isLoading = isElection ? candidatesQuery.isLoading : issuesQuery.isLoading; const { batchName } = getLocalizedProperties(batch, ["batchName"], language); const contentComponent = !isElection ? ( <VotingContent onOpenChange={onOpenChange} isLoading={isLoading} data={data as ApiResponse<BatchIssuesResponse | null>} onShowResultDialog={onShowResultDialog} invalidateBatchesQuery={refetchBatches} /> ) : ( <ElectionContent onOpenChange={onOpenChange} data={data as ApiResponse<BatchCandidatesResponse | null>} isLoading={isLoading} onShowResultDialog={onShowResultDialog} invalidateBatchesQuery={refetchBatches} /> ); const headerComponent = <VotingElectionHeader batch={batch} />; if (isMobile) { return ( <Drawer open={open} onOpenChange={onOpenChange}> <DrawerContent className="max-h-[90vh]!"> <DrawerHeader className="border-b-2 border-background-02 gap-2"> <div className="flex items-center justify-between"> <DrawerTitle className="text-left text-foreground-12 font-bold responsive-text-3xl"> {batchName} </DrawerTitle> <DrawerClose asChild> <Button variant="ghost" className="size-6 p-0!"> <CancelIcon className="size-6" /> <span className="sr-only">Close</span> </Button> </DrawerClose> </div> {headerComponent} </DrawerHeader> <div className="flex-1 overflow-auto">{contentComponent}</div> </DrawerContent> </Drawer> ); } return ( <CommonDialog noTrans title={batchName} content={contentComponent} open={open} className={cn( "max-w-[360px] xs:max-w-[600px] sm:max-w-[800px] gap-0", "p-0! [&>[data-slot='dialog-header']]:p-4 [&>[data-slot='dialog-header']]:border-b-2 [&>[data-slot='dialog-header']]:border-background-02 [&>[data-slot='dialog-close']]:top-4 [&>[data-slot='dialog-close']]:right-4" )} showCloseBtn={false} onOpenChange={onOpenChange} header={headerComponent} /> ); }; const VotingElectionHeader = ({ batch }: { batch: IssuesBatchResponse }) => { const { t } = useTranslation(["overview"]); return ( <div className="flex items-stretch justify-between"> <Badge variant="outlineDestructive" className="h-[unset]"> <HourglassEagerIcon className="!size-4" /> {t("voteElection.endTime", { endTime: batch.endTime && format(batch.endTime, TIME_FORMAT), })} </Badge> <Button isSmall variant="secondary" className={cn( "max-h-8 p-1.5 responsive-text-sm", getBadgeColorByStatus(batch.votingStatus) )} > {batch.votingStatus == "CHOBOPHIEU" ? t("voteElection.status.waiting") : batch.votingStatus == "DABOPHIEU" ? t("voteElection.status.voted") : t("voteElection.status.closed")} </Button> </div> ); }; const VotingContent = ({ onOpenChange, isLoading, data, onShowResultDialog, invalidateBatchesQuery, }: { onOpenChange: (open: boolean) => void; isLoading: boolean; data: ApiResponse<BatchIssuesResponse | null>; onShowResultDialog: (open: { open: boolean; content: string }) => void; invalidateBatchesQuery: RefetchProps; }) => { const { sendOtpVerify } = useAuthApi(); const { showError } = useError(); const { t, i18n } = useTranslation(["overview"]); const [showOtpDialog, setShowOtpDialog] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false); const language = i18n.language as Language; const form = useForm<VotingFormValues>({ resolver: zodResolver(votingSchema), defaultValues: { batchVote: VOTE_OPTIONS.NONE, individualVotes: {}, }, }); const isVerifyOtp = data?.data?.isRequestOtp; const isDisabled = !data?.data?.isAllowedToVote; const handleOpenDialog = () => { setShowOtpDialog(true); setIsDialogOpen(true); }; const handleCloseDialog = (open: boolean) => { setIsDialogOpen(open); }; // Side effect to initialize form values when data changes useEffect(() => { if (data?.data?.votingIssues) { const initialVotes = data.data.votingIssues.reduce< Record<string, VoteOption> >((acc, cur) => { acc[cur.issueId.toString()] = cur.eVote; return acc; }, {}); // Determine initial batch vote const votes = Object.values(initialVotes); const initialBatchVote = votes.length > 0 && votes.every(vote => vote === votes[0] && vote !== VOTE_OPTIONS.NONE) ? votes[0] : VOTE_OPTIONS.NONE; // Update form values form.setValue("individualVotes", initialVotes); form.setValue("batchVote", initialBatchVote); } }, [data?.data?.votingIssues, form]); const addVoteIssuesMutation = useMutation(voteIssuesOptions()); const [dialog, setDialog] = useState<DialogType>({ open: false, isSuccess: true, message: "", type: "none", }); const handleSubmit = async (params: VotingFormValues) => { // Handle voting form submission console.log("Voting data:", params); if (isVerifyOtp) { sendOtpVerify.mutate(undefined, { onSuccess: function ({ statusCode, ticketId, errorInfo }) { if (isHttpStatusSuccess(statusCode)) { handleOpenDialog(); } else { showError( createErrorWithFallback( { errorInfo, statusCode, }, { details: ticketId || undefined, showErrorCode: true } ) ); } }, }); return; } mutationForm(); }; const onSubmit = form.handleSubmit(handleSubmit); const mutationForm = async (otp?: string) => { if (!data.data) return; const { errorInfo, statusCode, ticketId } = await addVoteIssuesMutation.mutateAsync({ batchId: data.data.batchId, shareQtyConfirm: data.data.shareQtyConfirm, issues: Object.entries(form.getValues().individualVotes).map( ([issueId, vote]) => ({ issueId: Number(issueId), eVote: vote, }) ), otp, }); if (!errorInfo && isHttpStatusSuccess(statusCode)) { await invalidateBatchesQuery(); onShowResultDialog({ open: true, content: t("voteElection.vote.success"), }); handleCloseDialog(false); onOpenChange(false); } else { showError( createErrorWithFallback( { errorInfo, statusCode, }, { details: ticketId || undefined, showErrorCode: true } ) ); } }; // Watch for batchVote changes and update all individual votes const batchVote = useWatch({ control: form.control, name: "batchVote" }); useEffect(() => { // Only update individual votes when batchVote has a valid value (not empty string) if (batchVote !== VOTE_OPTIONS.NONE && batchVote !== undefined) { const newIndividualVotes = data.data?.votingIssues?.reduce( (acc, item) => { acc[item.issueId.toString()] = batchVote; return acc; }, {} as Record<string, VoteOption> ); if (newIndividualVotes) { form.setValue("individualVotes", newIndividualVotes); } } }, [batchVote, form]); // Handle form errors useEffect(() => { if (Object.keys(form.formState.errors).length > 0) { // Handle errors here const errors = Object.values(form.formState.errors); const message = errors.map((err, i) => { const code = err.message?.toString() || err.root?.message; return ( <p key={i}> {errors.length > 1 && "• "} {t(code)} </p> ); }); setDialog({ open: true, message, isSuccess: false, type: "error" }); } }, [form.formState.errors]); return ( <Form {...form}> <div className="px-4 py-3.5"> <div className="flex flex-col gap-3"> {/* Bỏ phiếu hàng loạt */} <div className="grid grid-cols-1 gap-y-2 xs:grid-cols-3 sm:grid-cols-2 px-2.5"> <h2 className="responsive-text text-primary font-semibold text-left"> {t("voteElection.vote.title")} </h2> <FormField disabled={isDisabled} control={form.control} name="batchVote" render={({ field }) => ( <FormItem className="xs:col-span-2 sm:col-span-1"> <FormControl> <RadioGroup value={field.value} onValueChange={field.onChange} className="grid grid-cols-3 gap-5 justify-items-start xs:justify-items-end" > <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.AGREE} id="batch-approve" /> <Label htmlFor="batch-approve" className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.agree")} </Label> </div> <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.DISAGREE} id="batch-disapprove" /> <Label htmlFor="batch-disapprove" className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.disagree")} </Label> </div> <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.NOIDEA} id="batch-abstain" /> <Label htmlFor="batch-abstain" className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.noOption")} </Label> </div> </RadioGroup> </FormControl> </FormItem> )} /> </div> {/* Bỏ phiếu từng biểu quyết */} {!isLoading ? ( data?.data ? ( <ScrollArea className="rounded-default"> {data.data.votingIssues ? ( data.data.votingIssues?.map((item, index) => ( <VotingItem key={item.issueId} item={item} isDisabled={isDisabled} length={data.data?.votingIssues?.length || 0} index={index} form={form} /> )) ) : ( <NoContent /> )} </ScrollArea> ) : ( <ErrorContent errorInfo={data?.errorInfo} className="m-4" /> ) ) : ( <div className="relative min-h-[150px]"> <Skeleton className="h-full rounded-default" /> <LoadingOverlay visible={isLoading} /> </div> )} </div> </div> <VotingElectionFooter onSubmit={onSubmit} isDisabled={isDisabled || isDialogOpen} batchId={data?.data?.batchId} batchName={ language === "vi" ? data?.data?.batchName : data?.data?.batchNameEn } batchType={data?.data?.batchType} /> <CommonDialog className="md:max-w-sm!" open={dialog.open} onOpenChange={(open: boolean) => { if (!open && !dialog.isSuccess) form.clearErrors(); if (!open && dialog.isSuccess && dialog.type !== "none") onOpenChange(false); setDialog({ ...dialog, open }); }} content={dialog.message} iconType={dialog.isSuccess ? "success" : "error"} /> {showOtpDialog && ( <Suspense fallback={<LoadingOverlay visible={true} />}> <LazyOtpVerificationDialog open={isDialogOpen} onOpenChange={handleCloseDialog} onConfirm={mutationForm} /> </Suspense> )} </Form> ); }; const ElectionContent = ({ onOpenChange, data, isLoading, onShowResultDialog, invalidateBatchesQuery, }: { onOpenChange: (open: boolean) => void; data: ApiResponse<BatchCandidatesResponse | null>; isLoading: boolean; onShowResultDialog: (open: { open: boolean; content: string }) => void; invalidateBatchesQuery: RefetchProps; }) => { const { sendOtpVerify } = useAuthApi(); const { showError } = useError(); const { t, i18n } = useTranslation(["overview"]); const [showOtpDialog, setShowOtpDialog] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false); const language = i18n.language as Language; const isVerifyOtp = data?.data?.isRequestOtp; // Safe access to data with loading check const totalLimit = (!isLoading && data?.data?.candidateShareholder?.totalVotes) || 0; const isDisabled = !data?.data?.isAllowedToVote; // Create dynamic schema with the total limit from API const dynamicElectionSchema = createElectionSchema(totalLimit); const form = useForm<ElectionFormValues>({ resolver: zodResolver(dynamicElectionSchema), defaultValues: { candidates: {}, }, }); const handleOpenDialog = () => { setShowOtpDialog(true); setIsDialogOpen(true); }; const handleCloseDialog = (open: boolean) => { setIsDialogOpen(open); }; // Side effect to initialize form values when data changes useEffect(() => { if (!isLoading && data?.data?.votingCandidates) { const initialValues: Record<string, number> = {}; data.data.votingCandidates.forEach(candidate => { initialValues[candidate.candidateId.toString()] = candidate.ballotCounter || 0; }); form.setValue("candidates", initialValues); } }, [data?.data?.votingCandidates, isLoading]); const addVoteCandidatesMutation = useMutation(voteCandidatesOptions()); const [dialog, setDialog] = useState<DialogType>({ open: false, isSuccess: true, message: "", type: "none", }); const isMobileScreen = useMediaQuery({ maxWidth: 480 }); // Watch candidates form values and calculate total voted dynamically using useWatch (form.watch doesn't work) const watchedCandidates = useWatch({ control: form.control, name: "candidates", }) as Record<string, number> | undefined; const totalVoted = Object.values(watchedCandidates || {}).reduce( (sum, v) => sum + (typeof v === "number" ? v : Number(v || 0)), 0 ); const handleSubmit = async (data: ElectionFormValues) => { // Handle election form submission console.log("Election data:", data); if (totalVoted <= 0) { setDialog({ message: t("voteElection.election.error.minVote"), open: true, type: "error", isSuccess: false, }); return; } if (totalVoted < totalLimit) { setDialog({ message: t("voteElection.election.error.remainVote", { remaining: totalLimit - totalVoted, }), open: true, type: "none", isSuccess: true, }); return; } handleVerifyOtpBeforeSubmit(); }; const handleVerifyOtpBeforeSubmit = () => { if (isVerifyOtp) { sendOtpVerify.mutate(undefined, { onSuccess: function ({ statusCode, ticketId, errorInfo }) { if (isHttpStatusSuccess(statusCode)) { handleOpenDialog(); } else { showError( createErrorWithFallback( { errorInfo, statusCode, }, { details: ticketId || undefined, showErrorCode: true } ) ); } }, }); return; } mutationForm(); }; const onSubmit = form.handleSubmit(handleSubmit); const mutationForm = async (otp?: string) => { if (!data.data) return; const formData = form.getValues(); const { errorInfo, statusCode, ticketId } = await addVoteCandidatesMutation.mutateAsync({ batchId: data.data.batchId, batchType: data.data.batchType, candidates: Object.entries(formData.candidates).map( ([candidateId, ballotCounter]) => ({ candidateId: Number(candidateId), ballotCounter, }) ), otp, }); if (!errorInfo && isHttpStatusSuccess(statusCode)) { await invalidateBatchesQuery(); onShowResultDialog({ open: true, content: t("voteElection.election.success"), }); handleCloseDialog(false); onOpenChange(false); } else { showError( createErrorWithFallback( { errorInfo, statusCode, }, { details: ticketId || undefined, showErrorCode: true } ) ); } }; const candidates = (!isLoading && data?.data?.votingCandidates) || []; const [setCandidate, { add, has, remove, reset }] = useSet(new Set<number>()); // Initialize checkboxes for candidates with existing votes (ballotCounter > 0) useUpdateEffect(() => { if (!isLoading && candidates.length > 0) { // Clear existing selections first reset(); // Calculate candidates with votes > 0 const candidatesWithVotes = candidates.filter( candidate => candidate.ballotCounter > 0 ); if (candidatesWithVotes.length > 0) { // Calculate average votes per candidate with votes const avgVotesPerCandidate = Math.floor( totalLimit / candidatesWithVotes.length ); // Only check candidates whose ballotCounter equals the average candidates.forEach(candidate => { if (candidate.ballotCounter === avgVotesPerCandidate) { add(candidate.candidateId); } }); } // Initialize form values with existing ballotCounter values (preserve actual data) const formValues: Record<string, number> = {}; candidates.forEach(candidate => { formValues[candidate.candidateId.toString()] = candidate.ballotCounter || 0; }); form.setValue("candidates", formValues); } }, [candidates, isLoading, totalLimit]); // Handle manual checkbox changes by user interaction const handleCheckboxChange = (candidateId: number, checked: boolean) => { // compute previous size before mutating the set to avoid off-by-one const prevSize = setCandidate.size; if (checked) { add(candidateId); } else { remove(candidateId); } // Calculate new selection set size after the change const newSelectionSize = checked ? prevSize + 1 : Math.max(0, prevSize - 1); if (newSelectionSize > 0) { const avgVotes = Math.floor(totalLimit / newSelectionSize); const formValues: Record<string, number> = {}; candidates.forEach(candidate => { const willBeSelected = candidate.candidateId === candidateId ? checked : has(candidate.candidateId); formValues[candidate.candidateId.toString()] = willBeSelected ? avgVotes : 0; }); form.setValue("candidates", formValues); } else { // Reset all values to 0 when no candidates are selected const formValues: Record<string, number> = {}; candidates.forEach(candidate => { formValues[candidate.candidateId.toString()] = 0; }); form.setValue("candidates", formValues); } }; return ( <Form {...form}> {!isLoading ? ( data?.data ? ( <div className="flex flex-col gap-3.5 px-4 pt-2.5 pb-3"> <div className="flex flex-col gap-2"> <h2 className="responsive-text text-primary font-semibold text-left"> {t("voteElection.election.numElectionInfo")} </h2> <div className="flex max-sm:overflow-x-auto max-sm:overflow-y-hidden gap-3 pb-1"> <div className="max-sm:flex-shrink-0 max-w-40 xs:max-w-52 sm:max-w-64 lg:max-w-none"> <NumberOfShares title={t("shares.shareQtyConfirm")} count={ data?.data.candidateShareholder?.shareQtyConfirm || 0 } /> </div> <div className="max-sm:flex-shrink-0 max-w-40 xs:max-w-52 sm:max-w-64 lg:max-w-none"> <NumberOfShares title={t("shares.candidateCounter")} count={ data?.data.candidateShareholder?.candidateCounter || 0 } /> </div> <div className="max-sm:flex-shrink-0 max-w-40 xs:max-w-52 sm:max-w-64 lg:max-w-none"> <NumberOfShares title={t("shares.totalVotes")} count={data?.data.candidateShareholder?.totalVotes || 0} /> </div> </div> </div> <div className="flex flex-col gap-2 mb-4 rounded-lg"> <h2 className="responsive-text text-primary font-semibold text-left"> {t("voteElection.election.infoCandidate")} </h2> <Table className="text-left"> <TableHeader> <TableRow> <TableHead> {t("voteElection.election.table.candidateName", { tag: isMobileScreen ? <br key="br-1" /> : "", })} </TableHead> <TableHead className="text-center cursor-pointer" onClick={() => { if (setCandidate.size) { // Clear selection and reset form values reset(); form.reset(); } else { // Select all and distribute average votes candidates.forEach(i => add(i.candidateId)); const avgVotes = Math.floor( totalLimit / Math.max(1, candidates.length) ); const values: Record<string, number> = {}; candidates.forEach(c => { values[c.candidateId.toString()] = avgVotes; }); form.setValue("candidates", values); } }} > {t("voteElection.election.table.cumulativeVoting")}{" "} {isMobileScreen && <br key="br-2" />} </TableHead> <TableHead> {t("voteElection.election.table.numberVotes")} </TableHead> </TableRow> </TableHeader> <TableBody> {candidates.map(i => ( <TableRow key={i.candidateId}> <TableCell>{i.fullName}</TableCell> <TableCell className="text-center"> <Checkbox disabled={isDisabled} onCheckedChange={checked => { handleCheckboxChange( i.candidateId, checked === true ); }} checked={has(i.candidateId)} /> </TableCell> <TableCell> <FormField control={form.control} name={`candidates.${i.candidateId}`} render={({ field }) => ( <FormItem> <FormControl> <NumericFormat customInput={Input} placeholder={t( "voteElection.election.table.placeholder" )} className="px-3! py-1.5! min-w-28" disabled={!!setCandidate.size || isDisabled} value={field.value || ""} onValueChange={values => { const { floatValue } = values; field.onChange(floatValue || 0); }} thousandSeparator="," allowNegative={false} decimalScale={0} isAllowed={values => { const { floatValue } = values; return ( floatValue === undefined || floatValue >= 0 ); }} /> </FormControl> </FormItem> )} /> </TableCell> </TableRow> ))} </TableBody> <TableFooter> <TableRow> <TableCell className="text-foreground-05" colSpan={2}> {t("voteElection.election.table.totalVotes")} </TableCell> <TableCell className="text-foreground-05"> {totalVoted} </TableCell> </TableRow> </TableFooter> </Table> </div> </div> ) : ( <ErrorContent errorInfo={data?.errorInfo} className="m-4" /> ) ) : ( <div className="relative min-h-[400px]"> <Skeleton className="h-full rounded-default" /> <LoadingOverlay visible={isLoading} /> </div> )} <VotingElectionFooter onSubmit={onSubmit} isDisabled={isDisabled || isDialogOpen} batchId={data?.data?.batchId} batchName={ language === "vi" ? data?.data?.batchName : data?.data?.batchNameEn } batchType={data?.data?.batchType} /> {showOtpDialog && ( <Suspense fallback={<LoadingOverlay visible={true} />}> <LazyOtpVerificationDialog open={isDialogOpen} onOpenChange={handleCloseDialog} onConfirm={mutationForm} /> </Suspense> )} <CommonDialog open={dialog.open} onOpenChange={(open: boolean) => { if (!open && !dialog.isSuccess) form.clearErrors(); if (!open && dialog.isSuccess && dialog.type !== "none") onOpenChange(false); setDialog({ ...dialog, open }); }} rightButton={ dialog.isSuccess && dialog.type === "none" ? ( <Button className="flex-1" onClick={handleVerifyOtpBeforeSubmit}> {t("voteElection.confirm")} </Button> ) : null } content={dialog.message} iconType={dialog.type} className="md:max-w-sm!" /> </Form> ); }; const VotingItem = ({ item, index, form, length, isDisabled, }: { item: IssuesVoteDTO; index: number; form: UseFormReturn<VotingFormValues>; length: number; isDisabled: boolean; }) => { const { i18n, t } = useTranslation(["overview"]); const language = i18n.language as Language; const { content, description } = getLocalizedProperties( item, ["content", "description"], language ); return ( <div className={cn( "grid grid-cols-1 sm:grid-cols-2 gap-y-2 p-2.5 rounded-none bg-background-06", index < length - 1 && "border-b-2 border-background" )} > <div className="text-foreground-05 text-left flex flex-col gap-1.5"> <h3 className="responsive-text font-semibold">{content}</h3> <p className="responsive-text-sm">{description}</p> </div> <FormField disabled={isDisabled} control={form.control} name={`individualVotes.${item.issueId}`} render={({ field }) => ( <FormItem> <FormControl> <RadioGroup value={field.value} onValueChange={value => { // Clear batch vote when individual vote is changed form.setValue("batchVote", VOTE_OPTIONS.NONE); field.onChange(value); }} className="grid grid-cols-3 gap-5 justify-items-start sm:justify-items-end content-start" > <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.AGREE} id={`approve-${item.issueId}`} /> <Label htmlFor={`approve-${item.issueId}`} className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.agree")} </Label> </div> <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.DISAGREE} id={`disapprove-${item.issueId}`} /> <Label htmlFor={`disapprove-${item.issueId}`} className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.disagree")} </Label> </div> <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.NOIDEA} id={`abstain-${item.issueId}`} /> <Label htmlFor={`abstain-${item.issueId}`} className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.noOption")} </Label> </div> </RadioGroup> </FormControl> </FormItem> )} /> </div> ); }; const VotingElectionFooter = ({ onSubmit, isDisabled, batchId, batchName, batchType, }: { onSubmit?: ( e?: BaseSyntheticEvent<object, any, any> | undefined ) => Promise<void>; isDisabled: boolean; batchId?: string; batchName?: string; batchType?: BatchType; }) => { const form = useFormContext(); const [isLoading, setIsLoading] = useState(false); const { showError } = useError(); const isSubmitting = form.formState.isSubmitting; const { t } = useTranslation(["overview"]); const { i18n } = useTranslation(); const language = i18n.language as Language; const queryClient = useQueryClient(); const [dialog, setDialog] = useState<{ open: boolean; data: MeetingInfoDTO[]; }>({ data: [], open: false, }); const handleFetchGuide = async () => { if (!batchId) return alert("Missing batchId"); const data = await queryClient.fetchQuery( informationQueryOptions(INFO_TYPES.TAILIEU, batchId) ); setDialog({ open: true, data: data.data || [] }); }; const handleDownloadPdf = async (batchId?: string) => { try { if (!batchId) { alert("Missing batchId"); return; } setIsLoading(true); const isVoting = batchType === "BIEUQUYET"; const blobData = await queryClient.fetchQuery( isVoting ? downloadVotingPdf(batchId) : downloadElectionPdf(batchId) ); // The API returns Uint8Array for non-JSON responses (PDFs, etc.) const blob = new Blob([blobData], { type: "application/pdf", }); const blobUrl = URL.createObjectURL(blob); // Create a temporary anchor element to trigger download const link = document.createElement("a"); link.href = blobUrl; const linkName = language === "vi" ? isVoting ? "Giấy biểu quyết" : "Giấy bầu cử" : isVoting ? "Voting form" : "Election form"; link.download = `${linkName} ${batchName}.pdf`; document.body.appendChild(link); link.click(); // Cleanup document.body.removeChild(link); URL.revokeObjectURL(blobUrl); } catch (error) { showError( createErrorWithFallback( error as { errorInfo?: { errorCode?: string; errorMessageVI?: string; errorMessageEN?: string; }; statusCode?: number; }, { showErrorCode: true, } ) ); } finally { setIsLoading(false); } }; return ( <div className="flex flex-col xs:flex-row gap-x-6 gap-y-3 px-4 pb-3"> <div className="flex gap-3 items-center"> <Button type="button" variant="secondary" className="max-h-8 xs:max-h-[unset]" disabled={isLoading} onClick={() => handleDownloadPdf(batchId)} > {!isLoading ? <DownloadIcon /> : <Loader className="animate-spin" />} {t("voteElection.downloadBallot")} </Button> <Button type="button" variant="secondary" className="max-h-8 xs:max-h-[unset]" onClick={handleFetchGuide} > <DocumentIcon className="[&>path]:stroke-primary" /> {t("voteElection.manual")} </Button> </div> {onSubmit && ( <Button type="submit" onClick={onSubmit} className="xs:flex-1" disabled={isDisabled || isSubmitting} > {isSubmitting ? ( <div className="flex items-center justify-center gap-2"> <Loader className="animate-spin flex-1" /> <span>{t("voteElection.confirm") + "..."}</span> </div> ) : ( t("voteElection.confirm") )} </Button> )} <LazyMeetingDocumentDialog open={dialog.open} onOpenChange={open => { if (!open) setDialog({ ...dialog, open }); }} meetingInfos={dialog.data} /> </div> ); }; export default VotingElectionDialog;3424import CommonDialog, { type IconType } from "@/components/dialog"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { Form, FormControl, FormField, FormItem } from "@/components/ui/form"; import { Drawer, DrawerClose, DrawerContent, DrawerHeader, DrawerTitle, } from "@/components/ui/drawer"; import DocumentIcon from "@/icons/document-icon"; import DownloadIcon from "@/icons/download-icon"; import HourglassEagerIcon from "@/icons/hourglass-eager-icon"; import { cn } from "@/lib/utils"; import { useForm, useFormContext, useWatch, type UseFormReturn, } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import type { BaseSyntheticEvent } from "react"; import { lazy, Suspense, useEffect, useState } from "react"; import NumberOfShares from "./number-of-shares"; import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { useSet, useUpdateEffect } from "react-use"; import { ScrollArea } from "@/components/ui/scroll-area"; import { NumericFormat } from "react-number-format"; import type { BatchCandidatesResponse, BatchIssuesResponse, IssuesBatchResponse, IssuesVoteDTO, MeetingInfoDTO, } from "@/types/information.type"; import { BATCH_TYPES, INFO_TYPES, VOTE_OPTIONS, type BatchType, type VoteOption, type VoteStatus, } from "@/constants/params"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { candidatesByBatchIdQueryOptions, informationQueryOptions, issuesByBatchIdQueryOptions, } from "@/features/information/information.api"; import { getLocalizedProperties, isHttpStatusSuccess } from "@/lib/helper"; import { useTranslation } from "react-i18next"; import type { Language } from "@/constants/language"; import { format } from "date-fns"; import { TIME_FORMAT } from "@/constants/format"; import type { ApiResponse } from "@/types/api.type"; import { Skeleton } from "@/components/ui/skeleton"; import { LoadingOverlay } from "@/components/ui/loading-overlay"; import { useMediaQuery } from "react-responsive"; import CancelIcon from "@/icons/cancel-icon"; import NoContent from "@/components/no-content"; import ErrorContent from "@/components/error-content"; import { downloadElectionPdf, downloadVotingPdf, voteCandidatesOptions, voteIssuesOptions, } from "@/features/meeting/meeting.api"; import { useError } from "@/contexts/error-provider"; import { createErrorWithFallback } from "@/lib/error-utils"; import type { RefetchProps } from "../$meetId/-components/batch-list"; import useAuthApi from "@/features/auth/auth.api"; import { Loader } from "lucide-react"; const LazyMeetingDocumentDialog = lazy( () => import("@/components/meeting-document-dialog") ); const LazyOtpVerificationDialog = lazy(() => import("@/components/otp-dialog")); interface IProps { open: boolean; onOpenChange: (open: boolean) => void; batch: IssuesBatchResponse; onShowResultDialog: (dialog: { open: boolean; content: string }) => void; refetchBatches: RefetchProps; } type DialogType = { open: boolean; isSuccess: boolean; message: React.ReactNode; type: IconType; }; export function getBadgeColorByStatus(status: VoteStatus) { switch (status) { case "CHOBOPHIEU": return "bg-primary-03 text-white"; case "DABOPHIEU": return "bg-primary text-white"; default: return "bg-background-07 text-foreground-09 [&_svg_path]:!fill-[#c5c5c5] [&_svg_path]:!stroke-[#c5c5c5]"; } } // Function to create election schema with dynamic total limit const createElectionSchema = (totalLimit: number) => z .object({ candidates: z.record( z.string(), z.number().min(0, "voteElection.election.error.minVote") ), }) .refine( data => { const total = Object.values(data.candidates).reduce( (sum, votes) => sum + votes, 0 ); return total <= totalLimit; }, { message: "voteElection.election.error.maxVote", path: ["candidates"], } ); type ElectionFormValues = z.infer<ReturnType<typeof createElectionSchema>>; // Form schema for voting const votingSchema = z.object({ batchVote: z .enum([ VOTE_OPTIONS.AGREE, VOTE_OPTIONS.DISAGREE, VOTE_OPTIONS.NOIDEA, VOTE_OPTIONS.NONE, VOTE_OPTIONS.INVALID, ]) .optional(), individualVotes: z .record( z.string(), z.enum([ VOTE_OPTIONS.AGREE, VOTE_OPTIONS.DISAGREE, VOTE_OPTIONS.NOIDEA, VOTE_OPTIONS.NONE, VOTE_OPTIONS.INVALID, ]) ) .refine( record => { const selectedValues = Object.values(record); // Return expected result return selectedValues.every(value => value !== VOTE_OPTIONS.NONE); }, { error: "voteElection.vote.error" } ), }); type VotingFormValues = z.infer<typeof votingSchema>; const VotingElectionDialog = ({ open, onOpenChange, batch, onShowResultDialog, refetchBatches, }: IProps) => { const isElection = batch.batchType !== BATCH_TYPES.BIEUQUYET; const isMobile = useMediaQuery({ maxWidth: 768 }); // Use separate queries for different batch types - only run when dialog is actually open const issuesOptions = issuesByBatchIdQueryOptions(batch.batchId); const issuesQuery = useQuery({ ...issuesOptions, enabled: !isElection && open, queryKey: [...issuesOptions.queryKey, String(open)], // Refetch when dialog opens refetchOnMount: false, }); const candidatesOptions = candidatesByBatchIdQueryOptions(batch.batchId); const candidatesQuery = useQuery({ ...candidatesOptions, enabled: isElection && open, queryKey: [...candidatesOptions.queryKey, String(open)], // Refetch when dialog opens refetchOnMount: false, }); const { i18n } = useTranslation(); const language = i18n.language as Language; const data = isElection ? candidatesQuery.data : issuesQuery.data; const isLoading = isElection ? candidatesQuery.isLoading : issuesQuery.isLoading; const { batchName } = getLocalizedProperties(batch, ["batchName"], language); const contentComponent = !isElection ? ( <VotingContent onOpenChange={onOpenChange} isLoading={isLoading} data={data as ApiResponse<BatchIssuesResponse | null>} onShowResultDialog={onShowResultDialog} invalidateBatchesQuery={refetchBatches} /> ) : ( <ElectionContent onOpenChange={onOpenChange} data={data as ApiResponse<BatchCandidatesResponse | null>} isLoading={isLoading} onShowResultDialog={onShowResultDialog} invalidateBatchesQuery={refetchBatches} /> ); const headerComponent = <VotingElectionHeader batch={batch} />; if (isMobile) { return ( <Drawer open={open} onOpenChange={onOpenChange}> <DrawerContent className="max-h-[90vh]!"> <DrawerHeader className="border-b-2 border-background-02 gap-2"> <div className="flex items-center justify-between"> <DrawerTitle className="text-left text-foreground-12 font-bold responsive-text-3xl"> {batchName} </DrawerTitle> <DrawerClose asChild> <Button variant="ghost" className="size-6 p-0!"> <CancelIcon className="size-6" /> <span className="sr-only">Close</span> </Button> </DrawerClose> </div> {headerComponent} </DrawerHeader> <div className="flex-1 overflow-auto">{contentComponent}</div> </DrawerContent> </Drawer> ); } return ( <CommonDialog noTrans title={batchName} content={contentComponent} open={open} className={cn( "max-w-[360px] xs:max-w-[600px] sm:max-w-[800px] gap-0", "p-0! [&>[data-slot='dialog-header']]:p-4 [&>[data-slot='dialog-header']]:border-b-2 [&>[data-slot='dialog-header']]:border-background-02 [&>[data-slot='dialog-close']]:top-4 [&>[data-slot='dialog-close']]:right-4" )} showCloseBtn={false} onOpenChange={onOpenChange} header={headerComponent} /> ); }; const VotingElectionHeader = ({ batch }: { batch: IssuesBatchResponse }) => { const { t } = useTranslation(["overview"]); return ( <div className="flex items-stretch justify-between"> <Badge variant="outlineDestructive" className="h-[unset]"> <HourglassEagerIcon className="!size-4" /> {t("voteElection.endTime", { endTime: batch.endTime && format(batch.endTime, TIME_FORMAT), })} </Badge> <Button isSmall variant="secondary" className={cn( "max-h-8 p-1.5 responsive-text-sm", getBadgeColorByStatus(batch.votingStatus) )} > {batch.votingStatus == "CHOBOPHIEU" ? t("voteElection.status.waiting") : batch.votingStatus == "DABOPHIEU" ? t("voteElection.status.voted") : t("voteElection.status.closed")} </Button> </div> ); }; const VotingContent = ({ onOpenChange, isLoading, data, onShowResultDialog, invalidateBatchesQuery, }: { onOpenChange: (open: boolean) => void; isLoading: boolean; data: ApiResponse<BatchIssuesResponse | null>; onShowResultDialog: (open: { open: boolean; content: string }) => void; invalidateBatchesQuery: RefetchProps; }) => { const { sendOtpVerify } = useAuthApi(); const { showError } = useError(); const { t, i18n } = useTranslation(["overview"]); const [showOtpDialog, setShowOtpDialog] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false); const language = i18n.language as Language; const form = useForm<VotingFormValues>({ resolver: zodResolver(votingSchema), defaultValues: { batchVote: VOTE_OPTIONS.NONE, individualVotes: {}, }, }); const isVerifyOtp = data?.data?.isRequestOtp; const isDisabled = !data?.data?.isAllowedToVote; const handleOpenDialog = () => { setShowOtpDialog(true); setIsDialogOpen(true); }; const handleCloseDialog = (open: boolean) => { setIsDialogOpen(open); }; // Side effect to initialize form values when data changes useEffect(() => { if (data?.data?.votingIssues) { const initialVotes = data.data.votingIssues.reduce< Record<string, VoteOption> >((acc, cur) => { acc[cur.issueId.toString()] = cur.eVote; return acc; }, {}); // Determine initial batch vote const votes = Object.values(initialVotes); const initialBatchVote = votes.length > 0 && votes.every(vote => vote === votes[0] && vote !== VOTE_OPTIONS.NONE) ? votes[0] : VOTE_OPTIONS.NONE; // Update form values form.setValue("individualVotes", initialVotes); form.setValue("batchVote", initialBatchVote); } }, [data?.data?.votingIssues, form]); const addVoteIssuesMutation = useMutation(voteIssuesOptions()); const [dialog, setDialog] = useState<DialogType>({ open: false, isSuccess: true, message: "", type: "none", }); const handleSubmit = async (params: VotingFormValues) => { // Handle voting form submission console.log("Voting data:", params); if (isVerifyOtp) { sendOtpVerify.mutate(undefined, { onSuccess: function ({ statusCode, ticketId, errorInfo }) { if (isHttpStatusSuccess(statusCode)) { handleOpenDialog(); } else { showError( createErrorWithFallback( { errorInfo, statusCode, }, { details: ticketId || undefined, showErrorCode: true } ) ); } }, }); return; } mutationForm(); }; const onSubmit = form.handleSubmit(handleSubmit); const mutationForm = async (otp?: string) => { if (!data.data) return; const { errorInfo, statusCode, ticketId } = await addVoteIssuesMutation.mutateAsync({ batchId: data.data.batchId, shareQtyConfirm: data.data.shareQtyConfirm, issues: Object.entries(form.getValues().individualVotes).map( ([issueId, vote]) => ({ issueId: Number(issueId), eVote: vote, }) ), otp, }); if (!errorInfo && isHttpStatusSuccess(statusCode)) { await invalidateBatchesQuery(); onShowResultDialog({ open: true, content: t("voteElection.vote.success"), }); handleCloseDialog(false); onOpenChange(false); } else { showError( createErrorWithFallback( { errorInfo, statusCode, }, { details: ticketId || undefined, showErrorCode: true } ) ); } }; // Watch for batchVote changes and update all individual votes const batchVote = useWatch({ control: form.control, name: "batchVote" }); useEffect(() => { // Only update individual votes when batchVote has a valid value (not empty string) if (batchVote !== VOTE_OPTIONS.NONE && batchVote !== undefined) { const newIndividualVotes = data.data?.votingIssues?.reduce( (acc, item) => { acc[item.issueId.toString()] = batchVote; return acc; }, {} as Record<string, VoteOption> ); if (newIndividualVotes) { form.setValue("individualVotes", newIndividualVotes); } } }, [batchVote, form]); // Handle form errors useEffect(() => { if (Object.keys(form.formState.errors).length > 0) { // Handle errors here const errors = Object.values(form.formState.errors); const message = errors.map((err, i) => { const code = err.message?.toString() || err.root?.message; return ( <p key={i}> {errors.length > 1 && "• "} {t(code)} </p> ); }); setDialog({ open: true, message, isSuccess: false, type: "error" }); } }, [form.formState.errors]); return ( <Form {...form}> <div className="px-4 py-3.5"> <div className="flex flex-col gap-3"> {/* Bỏ phiếu hàng loạt */} <div className="grid grid-cols-1 gap-y-2 xs:grid-cols-3 sm:grid-cols-2 px-2.5"> <h2 className="responsive-text text-primary font-semibold text-left"> {t("voteElection.vote.title")} </h2> <FormField disabled={isDisabled} control={form.control} name="batchVote" render={({ field }) => ( <FormItem className="xs:col-span-2 sm:col-span-1"> <FormControl> <RadioGroup value={field.value} onValueChange={field.onChange} className="grid grid-cols-3 gap-5 justify-items-start xs:justify-items-end" > <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.AGREE} id="batch-approve" /> <Label htmlFor="batch-approve" className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.agree")} </Label> </div> <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.DISAGREE} id="batch-disapprove" /> <Label htmlFor="batch-disapprove" className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.disagree")} </Label> </div> <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.NOIDEA} id="batch-abstain" /> <Label htmlFor="batch-abstain" className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.noOption")} </Label> </div> </RadioGroup> </FormControl> </FormItem> )} /> </div> {/* Bỏ phiếu từng biểu quyết */} {!isLoading ? ( data?.data ? ( <ScrollArea className="rounded-default"> {data.data.votingIssues ? ( data.data.votingIssues?.map((item, index) => ( <VotingItem key={item.issueId} item={item} isDisabled={isDisabled} length={data.data?.votingIssues?.length || 0} index={index} form={form} /> )) ) : ( <NoContent /> )} </ScrollArea> ) : ( <ErrorContent errorInfo={data?.errorInfo} className="m-4" /> ) ) : ( <div className="relative min-h-[150px]"> <Skeleton className="h-full rounded-default" /> <LoadingOverlay visible={isLoading} /> </div> )} </div> </div> <VotingElectionFooter onSubmit={onSubmit} isDisabled={isDisabled || isDialogOpen} batchId={data?.data?.batchId} batchName={ language === "vi" ? data?.data?.batchName : data?.data?.batchNameEn } batchType={data?.data?.batchType} /> <CommonDialog className="md:max-w-sm!" open={dialog.open} onOpenChange={(open: boolean) => { if (!open && !dialog.isSuccess) form.clearErrors(); if (!open && dialog.isSuccess && dialog.type !== "none") onOpenChange(false); setDialog({ ...dialog, open }); }} content={dialog.message} iconType={dialog.isSuccess ? "success" : "error"} /> {showOtpDialog && ( <Suspense fallback={<LoadingOverlay visible={true} />}> <LazyOtpVerificationDialog open={isDialogOpen} onOpenChange={handleCloseDialog} onConfirm={mutationForm} /> </Suspense> )} </Form> ); }; const ElectionContent = ({ onOpenChange, data, isLoading, onShowResultDialog, invalidateBatchesQuery, }: { onOpenChange: (open: boolean) => void; data: ApiResponse<BatchCandidatesResponse | null>; isLoading: boolean; onShowResultDialog: (open: { open: boolean; content: string }) => void; invalidateBatchesQuery: RefetchProps; }) => { const { sendOtpVerify } = useAuthApi(); const { showError } = useError(); const { t, i18n } = useTranslation(["overview"]); const [showOtpDialog, setShowOtpDialog] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false); const language = i18n.language as Language; const isVerifyOtp = data?.data?.isRequestOtp; // Safe access to data with loading check const totalLimit = (!isLoading && data?.data?.candidateShareholder?.totalVotes) || 0; const isDisabled = !data?.data?.isAllowedToVote; // Create dynamic schema with the total limit from API const dynamicElectionSchema = createElectionSchema(totalLimit); const form = useForm<ElectionFormValues>({ resolver: zodResolver(dynamicElectionSchema), defaultValues: { candidates: {}, }, }); const handleOpenDialog = () => { setShowOtpDialog(true); setIsDialogOpen(true); }; const handleCloseDialog = (open: boolean) => { setIsDialogOpen(open); }; // Side effect to initialize form values when data changes useEffect(() => { if (!isLoading && data?.data?.votingCandidates) { const initialValues: Record<string, number> = {}; data.data.votingCandidates.forEach(candidate => { initialValues[candidate.candidateId.toString()] = candidate.ballotCounter || 0; }); form.setValue("candidates", initialValues); } }, [data?.data?.votingCandidates, isLoading]); const addVoteCandidatesMutation = useMutation(voteCandidatesOptions()); const [dialog, setDialog] = useState<DialogType>({ open: false, isSuccess: true, message: "", type: "none", }); const isMobileScreen = useMediaQuery({ maxWidth: 480 }); // Watch candidates form values and calculate total voted dynamically using useWatch (form.watch doesn't work) const watchedCandidates = useWatch({ control: form.control, name: "candidates", }) as Record<string, number> | undefined; const totalVoted = Object.values(watchedCandidates || {}).reduce( (sum, v) => sum + (typeof v === "number" ? v : Number(v || 0)), 0 ); const handleSubmit = async (data: ElectionFormValues) => { // Handle election form submission console.log("Election data:", data); if (totalVoted <= 0) { setDialog({ message: t("voteElection.election.error.minVote"), open: true, type: "error", isSuccess: false, }); return; } if (totalVoted < totalLimit) { setDialog({ message: t("voteElection.election.error.remainVote", { remaining: totalLimit - totalVoted, }), open: true, type: "none", isSuccess: true, }); return; } handleVerifyOtpBeforeSubmit(); }; const handleVerifyOtpBeforeSubmit = () => { if (isVerifyOtp) { sendOtpVerify.mutate(undefined, { onSuccess: function ({ statusCode, ticketId, errorInfo }) { if (isHttpStatusSuccess(statusCode)) { handleOpenDialog(); } else { showError( createErrorWithFallback( { errorInfo, statusCode, }, { details: ticketId || undefined, showErrorCode: true } ) ); } }, }); return; } mutationForm(); }; const onSubmit = form.handleSubmit(handleSubmit); const mutationForm = async (otp?: string) => { if (!data.data) return; const formData = form.getValues(); const { errorInfo, statusCode, ticketId } = await addVoteCandidatesMutation.mutateAsync({ batchId: data.data.batchId, batchType: data.data.batchType, candidates: Object.entries(formData.candidates).map( ([candidateId, ballotCounter]) => ({ candidateId: Number(candidateId), ballotCounter, }) ), otp, }); if (!errorInfo && isHttpStatusSuccess(statusCode)) { await invalidateBatchesQuery(); onShowResultDialog({ open: true, content: t("voteElection.election.success"), }); handleCloseDialog(false); onOpenChange(false); } else { showError( createErrorWithFallback( { errorInfo, statusCode, }, { details: ticketId || undefined, showErrorCode: true } ) ); } }; const candidates = (!isLoading && data?.data?.votingCandidates) || []; const [setCandidate, { add, has, remove, reset }] = useSet(new Set<number>()); // Initialize checkboxes for candidates with existing votes (ballotCounter > 0) useUpdateEffect(() => { if (!isLoading && candidates.length > 0) { // Clear existing selections first reset(); // Calculate candidates with votes > 0 const candidatesWithVotes = candidates.filter( candidate => candidate.ballotCounter > 0 ); if (candidatesWithVotes.length > 0) { // Calculate average votes per candidate with votes const avgVotesPerCandidate = Math.floor( totalLimit / candidatesWithVotes.length ); // Only check candidates whose ballotCounter equals the average candidates.forEach(candidate => { if (candidate.ballotCounter === avgVotesPerCandidate) { add(candidate.candidateId); } }); } // Initialize form values with existing ballotCounter values (preserve actual data) const formValues: Record<string, number> = {}; candidates.forEach(candidate => { formValues[candidate.candidateId.toString()] = candidate.ballotCounter || 0; }); form.setValue("candidates", formValues); } }, [candidates, isLoading, totalLimit]); // Handle manual checkbox changes by user interaction const handleCheckboxChange = (candidateId: number, checked: boolean) => { // compute previous size before mutating the set to avoid off-by-one const prevSize = setCandidate.size; if (checked) { add(candidateId); } else { remove(candidateId); } // Calculate new selection set size after the change const newSelectionSize = checked ? prevSize + 1 : Math.max(0, prevSize - 1); if (newSelectionSize > 0) { const avgVotes = Math.floor(totalLimit / newSelectionSize); const formValues: Record<string, number> = {}; candidates.forEach(candidate => { const willBeSelected = candidate.candidateId === candidateId ? checked : has(candidate.candidateId); formValues[candidate.candidateId.toString()] = willBeSelected ? avgVotes : 0; }); form.setValue("candidates", formValues); } else { // Reset all values to 0 when no candidates are selected const formValues: Record<string, number> = {}; candidates.forEach(candidate => { formValues[candidate.candidateId.toString()] = 0; }); form.setValue("candidates", formValues); } }; return ( <Form {...form}> {!isLoading ? ( data?.data ? ( <div className="flex flex-col gap-3.5 px-4 pt-2.5 pb-3"> <div className="flex flex-col gap-2"> <h2 className="responsive-text text-primary font-semibold text-left"> {t("voteElection.election.numElectionInfo")} </h2> <div className="flex max-sm:overflow-x-auto max-sm:overflow-y-hidden gap-3 pb-1"> <div className="max-sm:flex-shrink-0 max-w-40 xs:max-w-52 sm:max-w-64 lg:max-w-none"> <NumberOfShares title={t("shares.shareQtyConfirm")} count={ data?.data.candidateShareholder?.shareQtyConfirm || 0 } /> </div> <div className="max-sm:flex-shrink-0 max-w-40 xs:max-w-52 sm:max-w-64 lg:max-w-none"> <NumberOfShares title={t("shares.candidateCounter")} count={ data?.data.candidateShareholder?.candidateCounter || 0 } /> </div> <div className="max-sm:flex-shrink-0 max-w-40 xs:max-w-52 sm:max-w-64 lg:max-w-none"> <NumberOfShares title={t("shares.totalVotes")} count={data?.data.candidateShareholder?.totalVotes || 0} /> </div> </div> </div> <div className="flex flex-col gap-2 mb-4 rounded-lg"> <h2 className="responsive-text text-primary font-semibold text-left"> {t("voteElection.election.infoCandidate")} </h2> <Table className="text-left"> <TableHeader> <TableRow> <TableHead> {t("voteElection.election.table.candidateName", { tag: isMobileScreen ? <br key="br-1" /> : "", })} </TableHead> <TableHead className="text-center cursor-pointer" onClick={() => { if (setCandidate.size) { // Clear selection and reset form values reset(); form.reset(); } else { // Select all and distribute average votes candidates.forEach(i => add(i.candidateId)); const avgVotes = Math.floor( totalLimit / Math.max(1, candidates.length) ); const values: Record<string, number> = {}; candidates.forEach(c => { values[c.candidateId.toString()] = avgVotes; }); form.setValue("candidates", values); } }} > {t("voteElection.election.table.cumulativeVoting")}{" "} {isMobileScreen && <br key="br-2" />} </TableHead> <TableHead> {t("voteElection.election.table.numberVotes")} </TableHead> </TableRow> </TableHeader> <TableBody> {candidates.map(i => ( <TableRow key={i.candidateId}> <TableCell>{i.fullName}</TableCell> <TableCell className="text-center"> <Checkbox disabled={isDisabled} onCheckedChange={checked => { handleCheckboxChange( i.candidateId, checked === true ); }} checked={has(i.candidateId)} /> </TableCell> <TableCell> <FormField control={form.control} name={`candidates.${i.candidateId}`} render={({ field }) => ( <FormItem> <FormControl> <NumericFormat customInput={Input} placeholder={t( "voteElection.election.table.placeholder" )} className="px-3! py-1.5! min-w-28" disabled={!!setCandidate.size || isDisabled} value={field.value || ""} onValueChange={values => { const { floatValue } = values; field.onChange(floatValue || 0); }} thousandSeparator="," allowNegative={false} decimalScale={0} isAllowed={values => { const { floatValue } = values; return ( floatValue === undefined || floatValue >= 0 ); }} /> </FormControl> </FormItem> )} /> </TableCell> </TableRow> ))} </TableBody> <TableFooter> <TableRow> <TableCell className="text-foreground-05" colSpan={2}> {t("voteElection.election.table.totalVotes")} </TableCell> <TableCell className="text-foreground-05"> {totalVoted} </TableCell> </TableRow> </TableFooter> </Table> </div> </div> ) : ( <ErrorContent errorInfo={data?.errorInfo} className="m-4" /> ) ) : ( <div className="relative min-h-[400px]"> <Skeleton className="h-full rounded-default" /> <LoadingOverlay visible={isLoading} /> </div> )} <VotingElectionFooter onSubmit={onSubmit} isDisabled={isDisabled || isDialogOpen} batchId={data?.data?.batchId} batchName={ language === "vi" ? data?.data?.batchName : data?.data?.batchNameEn } batchType={data?.data?.batchType} /> {showOtpDialog && ( <Suspense fallback={<LoadingOverlay visible={true} />}> <LazyOtpVerificationDialog open={isDialogOpen} onOpenChange={handleCloseDialog} onConfirm={mutationForm} /> </Suspense> )} <CommonDialog open={dialog.open} onOpenChange={(open: boolean) => { if (!open && !dialog.isSuccess) form.clearErrors(); if (!open && dialog.isSuccess && dialog.type !== "none") onOpenChange(false); setDialog({ ...dialog, open }); }} rightButton={ dialog.isSuccess && dialog.type === "none" ? ( <Button className="flex-1" onClick={handleVerifyOtpBeforeSubmit}> {t("voteElection.confirm")} </Button> ) : null } content={dialog.message} iconType={dialog.type} className="md:max-w-sm!" /> </Form> ); }; const VotingItem = ({ item, index, form, length, isDisabled, }: { item: IssuesVoteDTO; index: number; form: UseFormReturn<VotingFormValues>; length: number; isDisabled: boolean; }) => { const { i18n, t } = useTranslation(["overview"]); const language = i18n.language as Language; const { content, description } = getLocalizedProperties( item, ["content", "description"], language ); return ( <div className={cn( "grid grid-cols-1 sm:grid-cols-2 gap-y-2 p-2.5 rounded-none bg-background-06", index < length - 1 && "border-b-2 border-background" )} > <div className="text-foreground-05 text-left flex flex-col gap-1.5"> <h3 className="responsive-text font-semibold">{content}</h3> <p className="responsive-text-sm">{description}</p> </div> <FormField disabled={isDisabled} control={form.control} name={`individualVotes.${item.issueId}`} render={({ field }) => ( <FormItem> <FormControl> <RadioGroup value={field.value} onValueChange={value => { // Clear batch vote when individual vote is changed form.setValue("batchVote", VOTE_OPTIONS.NONE); field.onChange(value); }} className="grid grid-cols-3 gap-5 justify-items-start sm:justify-items-end content-start" > <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.AGREE} id={`approve-${item.issueId}`} /> <Label htmlFor={`approve-${item.issueId}`} className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.agree")} </Label> </div> <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.DISAGREE} id={`disapprove-${item.issueId}`} /> <Label htmlFor={`disapprove-${item.issueId}`} className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.disagree")} </Label> </div> <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.NOIDEA} id={`abstain-${item.issueId}`} /> <Label htmlFor={`abstain-${item.issueId}`} className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.noOption")} </Label> </div> </RadioGroup> </FormControl> </FormItem> )} /> </div> ); }; const VotingElectionFooter = ({ onSubmit, isDisabled, batchId, batchName, batchType, }: { onSubmit?: ( e?: BaseSyntheticEvent<object, any, any> | undefined ) => Promise<void>; isDisabled: boolean; batchId?: string; batchName?: string; batchType?: BatchType; }) => { const form = useFormContext(); const [isLoading, setIsLoading] = useState(false); const { showError } = useError(); const isSubmitting = form.formState.isSubmitting; const { t } = useTranslation(["overview"]); const { i18n } = useTranslation(); const language = i18n.language as Language; const queryClient = useQueryClient(); const [dialog, setDialog] = useState<{ open: boolean; data: MeetingInfoDTO[]; }>({ data: [], open: false, }); const handleFetchGuide = async () => { if (!batchId) return alert("Missing batchId"); const data = await queryClient.fetchQuery( informationQueryOptions(INFO_TYPES.TAILIEU, batchId) ); setDialog({ open: true, data: data.data || [] }); }; const handleDownloadPdf = async (batchId?: string) => { try { if (!batchId) { alert("Missing batchId"); return; } setIsLoading(true); const isVoting = batchType === "BIEUQUYET"; const blobData = await queryClient.fetchQuery( isVoting ? downloadVotingPdf(batchId) : downloadElectionPdf(batchId) ); // The API returns Uint8Array for non-JSON responses (PDFs, etc.) const blob = new Blob([blobData], { type: "application/pdf", }); const blobUrl = URL.createObjectURL(blob); // Create a temporary anchor element to trigger download const link = document.createElement("a"); link.href = blobUrl; const linkName = language === "vi" ? isVoting ? "Giấy biểu quyết" : "Giấy bầu cử" : isVoting ? "Voting form" : "Election form"; link.download = `${linkName} ${batchName}.pdf`; document.body.appendChild(link); link.click(); // Cleanup document.body.removeChild(link); URL.revokeObjectURL(blobUrl); } catch (error) { showError( createErrorWithFallback( error as { errorInfo?: { errorCode?: string; errorMessageVI?: string; errorMessageEN?: string; }; statusCode?: number; }, { showErrorCode: true, } ) ); } finally { setIsLoading(false); } }; return ( <div className="flex flex-col xs:flex-row gap-x-6 gap-y-3 px-4 pb-3"> <div className="flex gap-3 items-center"> <Button type="button" variant="secondary" className="max-h-8 xs:max-h-[unset]" disabled={isLoading} onClick={() => handleDownloadPdf(batchId)} > {!isLoading ? <DownloadIcon /> : <Loader className="animate-spin" />} {t("voteElection.downloadBallot")} </Button> <Button type="button" variant="secondary" className="max-h-8 xs:max-h-[unset]" onClick={handleFetchGuide} > <DocumentIcon className="[&>path]:stroke-primary" /> {t("voteElection.manual")} </Button> </div> {onSubmit && ( <Button type="submit" onClick={onSubmit} className="xs:flex-1" disabled={isDisabled || isSubmitting} > {isSubmitting ? ( <div className="flex items-center justify-center gap-2"> <Loader className="animate-spin flex-1" /> <span>{t("voteElection.confirm") + "..."}</span> </div> ) : ( t("voteElection.confirm") )} </Button> )} <LazyMeetingDocumentDialog open={dialog.open} onOpenChange={open => { if (!open) setDialog({ ...dialog, open }); }} meetingInfos={dialog.data} /> </div> ); }; export default VotingElectionDialog;import CommonDialog, { type IconType } from "@/components/dialog"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { Form, FormControl, FormField, FormItem } from "@/components/ui/form"; import { Drawer, DrawerClose, DrawerContent, DrawerHeader, DrawerTitle, } from "@/components/ui/drawer"; import DocumentIcon from "@/icons/document-icon"; import DownloadIcon from "@/icons/download-icon"; import HourglassEagerIcon from "@/icons/hourglass-eager-icon"; import { cn } from "@/lib/utils"; import { useForm, useFormContext, useWatch, type UseFormReturn, } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import type { BaseSyntheticEvent } from "react"; import { lazy, Suspense, useEffect, useState } from "react"; import NumberOfShares from "./number-of-shares"; import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { useSet, useUpdateEffect } from "react-use"; import { ScrollArea } from "@/components/ui/scroll-area"; import { NumericFormat } from "react-number-format"; import type { BatchCandidatesResponse, BatchIssuesResponse, IssuesBatchResponse, IssuesVoteDTO, MeetingInfoDTO, } from "@/types/information.type"; import { BATCH_TYPES, INFO_TYPES, VOTE_OPTIONS, type BatchType, type VoteOption, type VoteStatus, } from "@/constants/params"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { candidatesByBatchIdQueryOptions, informationQueryOptions, issuesByBatchIdQueryOptions, } from "@/features/information/information.api"; import { getLocalizedProperties, isHttpStatusSuccess } from "@/lib/helper"; import { useTranslation } from "react-i18next"; import type { Language } from "@/constants/language"; import { format } from "date-fns"; import { TIME_FORMAT } from "@/constants/format"; import type { ApiResponse } from "@/types/api.type"; import { Skeleton } from "@/components/ui/skeleton"; import { LoadingOverlay } from "@/components/ui/loading-overlay"; import { useMediaQuery } from "react-responsive"; import CancelIcon from "@/icons/cancel-icon"; import NoContent from "@/components/no-content"; import ErrorContent from "@/components/error-content"; import { downloadElectionPdf, downloadVotingPdf, voteCandidatesOptions, voteIssuesOptions, } from "@/features/meeting/meeting.api"; import { useError } from "@/contexts/error-provider"; import { createErrorWithFallback } from "@/lib/error-utils"; import type { RefetchProps } from "../$meetId/-components/batch-list"; import useAuthApi from "@/features/auth/auth.api"; import { Loader } from "lucide-react"; const LazyMeetingDocumentDialog = lazy( () => import("@/components/meeting-document-dialog") ); const LazyOtpVerificationDialog = lazy(() => import("@/components/otp-dialog")); interface IProps { open: boolean; onOpenChange: (open: boolean) => void; batch: IssuesBatchResponse; onShowResultDialog: (dialog: { open: boolean; content: string }) => void; refetchBatches: RefetchProps; } type DialogType = { open: boolean; isSuccess: boolean; message: React.ReactNode; type: IconType; }; export function getBadgeColorByStatus(status: VoteStatus) { switch (status) { case "CHOBOPHIEU": return "bg-primary-03 text-white"; case "DABOPHIEU": return "bg-primary text-white"; default: return "bg-background-07 text-foreground-09 [&_svg_path]:!fill-[#c5c5c5] [&_svg_path]:!stroke-[#c5c5c5]"; } } // Function to create election schema with dynamic total limit const createElectionSchema = (totalLimit: number) => z .object({ candidates: z.record( z.string(), z.number().min(0, "voteElection.election.error.minVote") ), }) .refine( data => { const total = Object.values(data.candidates).reduce( (sum, votes) => sum + votes, 0 ); return total <= totalLimit; }, { message: "voteElection.election.error.maxVote", path: ["candidates"], } ); type ElectionFormValues = z.infer<ReturnType<typeof createElectionSchema>>; // Form schema for voting const votingSchema = z.object({ batchVote: z .enum([ VOTE_OPTIONS.AGREE, VOTE_OPTIONS.DISAGREE, VOTE_OPTIONS.NOIDEA, VOTE_OPTIONS.NONE, VOTE_OPTIONS.INVALID, ]) .optional(), individualVotes: z .record( z.string(), z.enum([ VOTE_OPTIONS.AGREE, VOTE_OPTIONS.DISAGREE, VOTE_OPTIONS.NOIDEA, VOTE_OPTIONS.NONE, VOTE_OPTIONS.INVALID, ]) ) .refine( record => { const selectedValues = Object.values(record); // Return expected result return selectedValues.every(value => value !== VOTE_OPTIONS.NONE); }, { error: "voteElection.vote.error" } ), }); type VotingFormValues = z.infer<typeof votingSchema>; const VotingElectionDialog = ({ open, onOpenChange, batch, onShowResultDialog, refetchBatches, }: IProps) => { const isElection = batch.batchType !== BATCH_TYPES.BIEUQUYET; const isMobile = useMediaQuery({ maxWidth: 768 }); // Use separate queries for different batch types - only run when dialog is actually open const issuesOptions = issuesByBatchIdQueryOptions(batch.batchId); const issuesQuery = useQuery({ ...issuesOptions, enabled: !isElection && open, queryKey: [...issuesOptions.queryKey, String(open)], // Refetch when dialog opens refetchOnMount: false, }); const candidatesOptions = candidatesByBatchIdQueryOptions(batch.batchId); const candidatesQuery = useQuery({ ...candidatesOptions, enabled: isElection && open, queryKey: [...candidatesOptions.queryKey, String(open)], // Refetch when dialog opens refetchOnMount: false, }); const { i18n } = useTranslation(); const language = i18n.language as Language; const data = isElection ? candidatesQuery.data : issuesQuery.data; const isLoading = isElection ? candidatesQuery.isLoading : issuesQuery.isLoading; const { batchName } = getLocalizedProperties(batch, ["batchName"], language); const contentComponent = !isElection ? ( <VotingContent onOpenChange={onOpenChange} isLoading={isLoading} data={data as ApiResponse<BatchIssuesResponse | null>} onShowResultDialog={onShowResultDialog} invalidateBatchesQuery={refetchBatches} /> ) : ( <ElectionContent onOpenChange={onOpenChange} data={data as ApiResponse<BatchCandidatesResponse | null>} isLoading={isLoading} onShowResultDialog={onShowResultDialog} invalidateBatchesQuery={refetchBatches} /> ); const headerComponent = <VotingElectionHeader batch={batch} />; if (isMobile) { return ( <Drawer open={open} onOpenChange={onOpenChange}> <DrawerContent className="max-h-[90vh]!"> <DrawerHeader className="border-b-2 border-background-02 gap-2"> <div className="flex items-center justify-between"> <DrawerTitle className="text-left text-foreground-12 font-bold responsive-text-3xl"> {batchName} </DrawerTitle> <DrawerClose asChild> <Button variant="ghost" className="size-6 p-0!"> <CancelIcon className="size-6" /> <span className="sr-only">Close</span> </Button> </DrawerClose> </div> {headerComponent} </DrawerHeader> <div className="flex-1 overflow-auto">{contentComponent}</div> </DrawerContent> </Drawer> ); } return ( <CommonDialog noTrans title={batchName} content={contentComponent} open={open} className={cn( "max-w-[360px] xs:max-w-[600px] sm:max-w-[800px] gap-0", "p-0! [&>[data-slot='dialog-header']]:p-4 [&>[data-slot='dialog-header']]:border-b-2 [&>[data-slot='dialog-header']]:border-background-02 [&>[data-slot='dialog-close']]:top-4 [&>[data-slot='dialog-close']]:right-4" )} showCloseBtn={false} onOpenChange={onOpenChange} header={headerComponent} /> ); }; const VotingElectionHeader = ({ batch }: { batch: IssuesBatchResponse }) => { const { t } = useTranslation(["overview"]); return ( <div className="flex items-stretch justify-between"> <Badge variant="outlineDestructive" className="h-[unset]"> <HourglassEagerIcon className="!size-4" /> {t("voteElection.endTime", { endTime: batch.endTime && format(batch.endTime, TIME_FORMAT), })} </Badge> <Button isSmall variant="secondary" className={cn( "max-h-8 p-1.5 responsive-text-sm", getBadgeColorByStatus(batch.votingStatus) )} > {batch.votingStatus == "CHOBOPHIEU" ? t("voteElection.status.waiting") : batch.votingStatus == "DABOPHIEU" ? t("voteElection.status.voted") : t("voteElection.status.closed")} </Button> </div> ); }; const VotingContent = ({ onOpenChange, isLoading, data, onShowResultDialog, invalidateBatchesQuery, }: { onOpenChange: (open: boolean) => void; isLoading: boolean; data: ApiResponse<BatchIssuesResponse | null>; onShowResultDialog: (open: { open: boolean; content: string }) => void; invalidateBatchesQuery: RefetchProps; }) => { const { sendOtpVerify } = useAuthApi(); const { showError } = useError(); const { t, i18n } = useTranslation(["overview"]); const [showOtpDialog, setShowOtpDialog] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false); const language = i18n.language as Language; const form = useForm<VotingFormValues>({ resolver: zodResolver(votingSchema), defaultValues: { batchVote: VOTE_OPTIONS.NONE, individualVotes: {}, }, }); const isVerifyOtp = data?.data?.isRequestOtp; const isDisabled = !data?.data?.isAllowedToVote; const handleOpenDialog = () => { setShowOtpDialog(true); setIsDialogOpen(true); }; const handleCloseDialog = (open: boolean) => { setIsDialogOpen(open); }; // Side effect to initialize form values when data changes useEffect(() => { if (data?.data?.votingIssues) { const initialVotes = data.data.votingIssues.reduce< Record<string, VoteOption> >((acc, cur) => { acc[cur.issueId.toString()] = cur.eVote; return acc; }, {}); // Determine initial batch vote const votes = Object.values(initialVotes); const initialBatchVote = votes.length > 0 && votes.every(vote => vote === votes[0] && vote !== VOTE_OPTIONS.NONE) ? votes[0] : VOTE_OPTIONS.NONE; // Update form values form.setValue("individualVotes", initialVotes); form.setValue("batchVote", initialBatchVote); } }, [data?.data?.votingIssues, form]); const addVoteIssuesMutation = useMutation(voteIssuesOptions()); const [dialog, setDialog] = useState<DialogType>({ open: false, isSuccess: true, message: "", type: "none", }); const handleSubmit = async (params: VotingFormValues) => { // Handle voting form submission console.log("Voting data:", params); if (isVerifyOtp) { sendOtpVerify.mutate(undefined, { onSuccess: function ({ statusCode, ticketId, errorInfo }) { if (isHttpStatusSuccess(statusCode)) { handleOpenDialog(); } else { showError( createErrorWithFallback( { errorInfo, statusCode, }, { details: ticketId || undefined, showErrorCode: true } ) ); } }, }); return; } mutationForm(); }; const onSubmit = form.handleSubmit(handleSubmit); const mutationForm = async (otp?: string) => { if (!data.data) return; const { errorInfo, statusCode, ticketId } = await addVoteIssuesMutation.mutateAsync({ batchId: data.data.batchId, shareQtyConfirm: data.data.shareQtyConfirm, issues: Object.entries(form.getValues().individualVotes).map( ([issueId, vote]) => ({ issueId: Number(issueId), eVote: vote, }) ), otp, }); if (!errorInfo && isHttpStatusSuccess(statusCode)) { await invalidateBatchesQuery(); onShowResultDialog({ open: true, content: t("voteElection.vote.success"), }); handleCloseDialog(false); onOpenChange(false); } else { showError( createErrorWithFallback( { errorInfo, statusCode, }, { details: ticketId || undefined, showErrorCode: true } ) ); } }; // Watch for batchVote changes and update all individual votes const batchVote = useWatch({ control: form.control, name: "batchVote" }); useEffect(() => { // Only update individual votes when batchVote has a valid value (not empty string) if (batchVote !== VOTE_OPTIONS.NONE && batchVote !== undefined) { const newIndividualVotes = data.data?.votingIssues?.reduce( (acc, item) => { acc[item.issueId.toString()] = batchVote; return acc; }, {} as Record<string, VoteOption> ); if (newIndividualVotes) { form.setValue("individualVotes", newIndividualVotes); } } }, [batchVote, form]); // Handle form errors useEffect(() => { if (Object.keys(form.formState.errors).length > 0) { // Handle errors here const errors = Object.values(form.formState.errors); const message = errors.map((err, i) => { const code = err.message?.toString() || err.root?.message; return ( <p key={i}> {errors.length > 1 && "• "} {t(code)} </p> ); }); setDialog({ open: true, message, isSuccess: false, type: "error" }); } }, [form.formState.errors]); return ( <Form {...form}> <div className="px-4 py-3.5"> <div className="flex flex-col gap-3"> {/* Bỏ phiếu hàng loạt */} <div className="grid grid-cols-1 gap-y-2 xs:grid-cols-3 sm:grid-cols-2 px-2.5"> <h2 className="responsive-text text-primary font-semibold text-left"> {t("voteElection.vote.title")} </h2> <FormField disabled={isDisabled} control={form.control} name="batchVote" render={({ field }) => ( <FormItem className="xs:col-span-2 sm:col-span-1"> <FormControl> <RadioGroup value={field.value} onValueChange={field.onChange} className="grid grid-cols-3 gap-5 justify-items-start xs:justify-items-end" > <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.AGREE} id="batch-approve" /> <Label htmlFor="batch-approve" className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.agree")} </Label> </div> <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.DISAGREE} id="batch-disapprove" /> <Label htmlFor="batch-disapprove" className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.disagree")} </Label> </div> <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.NOIDEA} id="batch-abstain" /> <Label htmlFor="batch-abstain" className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.noOption")} </Label> </div> </RadioGroup> </FormControl> </FormItem> )} /> </div> {/* Bỏ phiếu từng biểu quyết */} {!isLoading ? ( data?.data ? ( <ScrollArea className="rounded-default"> {data.data.votingIssues ? ( data.data.votingIssues?.map((item, index) => ( <VotingItem key={item.issueId} item={item} isDisabled={isDisabled} length={data.data?.votingIssues?.length || 0} index={index} form={form} /> )) ) : ( <NoContent /> )} </ScrollArea> ) : ( <ErrorContent errorInfo={data?.errorInfo} className="m-4" /> ) ) : ( <div className="relative min-h-[150px]"> <Skeleton className="h-full rounded-default" /> <LoadingOverlay visible={isLoading} /> </div> )} </div> </div> <VotingElectionFooter onSubmit={onSubmit} isDisabled={isDisabled || isDialogOpen} batchId={data?.data?.batchId} batchName={ language === "vi" ? data?.data?.batchName : data?.data?.batchNameEn } batchType={data?.data?.batchType} /> <CommonDialog className="md:max-w-sm!" open={dialog.open} onOpenChange={(open: boolean) => { if (!open && !dialog.isSuccess) form.clearErrors(); if (!open && dialog.isSuccess && dialog.type !== "none") onOpenChange(false); setDialog({ ...dialog, open }); }} content={dialog.message} iconType={dialog.isSuccess ? "success" : "error"} /> {showOtpDialog && ( <Suspense fallback={<LoadingOverlay visible={true} />}> <LazyOtpVerificationDialog open={isDialogOpen} onOpenChange={handleCloseDialog} onConfirm={mutationForm} /> </Suspense> )} </Form> ); }; const ElectionContent = ({ onOpenChange, data, isLoading, onShowResultDialog, invalidateBatchesQuery, }: { onOpenChange: (open: boolean) => void; data: ApiResponse<BatchCandidatesResponse | null>; isLoading: boolean; onShowResultDialog: (open: { open: boolean; content: string }) => void; invalidateBatchesQuery: RefetchProps; }) => { const { sendOtpVerify } = useAuthApi(); const { showError } = useError(); const { t, i18n } = useTranslation(["overview"]); const [showOtpDialog, setShowOtpDialog] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false); const language = i18n.language as Language; const isVerifyOtp = data?.data?.isRequestOtp; // Safe access to data with loading check const totalLimit = (!isLoading && data?.data?.candidateShareholder?.totalVotes) || 0; const isDisabled = !data?.data?.isAllowedToVote; // Create dynamic schema with the total limit from API const dynamicElectionSchema = createElectionSchema(totalLimit); const form = useForm<ElectionFormValues>({ resolver: zodResolver(dynamicElectionSchema), defaultValues: { candidates: {}, }, }); const handleOpenDialog = () => { setShowOtpDialog(true); setIsDialogOpen(true); }; const handleCloseDialog = (open: boolean) => { setIsDialogOpen(open); }; // Side effect to initialize form values when data changes useEffect(() => { if (!isLoading && data?.data?.votingCandidates) { const initialValues: Record<string, number> = {}; data.data.votingCandidates.forEach(candidate => { initialValues[candidate.candidateId.toString()] = candidate.ballotCounter || 0; }); form.setValue("candidates", initialValues); } }, [data?.data?.votingCandidates, isLoading]); const addVoteCandidatesMutation = useMutation(voteCandidatesOptions()); const [dialog, setDialog] = useState<DialogType>({ open: false, isSuccess: true, message: "", type: "none", }); const isMobileScreen = useMediaQuery({ maxWidth: 480 }); // Watch candidates form values and calculate total voted dynamically using useWatch (form.watch doesn't work) const watchedCandidates = useWatch({ control: form.control, name: "candidates", }) as Record<string, number> | undefined; const totalVoted = Object.values(watchedCandidates || {}).reduce( (sum, v) => sum + (typeof v === "number" ? v : Number(v || 0)), 0 ); const handleSubmit = async (data: ElectionFormValues) => { // Handle election form submission console.log("Election data:", data); if (totalVoted <= 0) { setDialog({ message: t("voteElection.election.error.minVote"), open: true, type: "error", isSuccess: false, }); return; } if (totalVoted < totalLimit) { setDialog({ message: t("voteElection.election.error.remainVote", { remaining: totalLimit - totalVoted, }), open: true, type: "none", isSuccess: true, }); return; } handleVerifyOtpBeforeSubmit(); }; const handleVerifyOtpBeforeSubmit = () => { if (isVerifyOtp) { sendOtpVerify.mutate(undefined, { onSuccess: function ({ statusCode, ticketId, errorInfo }) { if (isHttpStatusSuccess(statusCode)) { handleOpenDialog(); } else { showError( createErrorWithFallback( { errorInfo, statusCode, }, { details: ticketId || undefined, showErrorCode: true } ) ); } }, }); return; } mutationForm(); }; const onSubmit = form.handleSubmit(handleSubmit); const mutationForm = async (otp?: string) => { if (!data.data) return; const formData = form.getValues(); const { errorInfo, statusCode, ticketId } = await addVoteCandidatesMutation.mutateAsync({ batchId: data.data.batchId, batchType: data.data.batchType, candidates: Object.entries(formData.candidates).map( ([candidateId, ballotCounter]) => ({ candidateId: Number(candidateId), ballotCounter, }) ), otp, }); if (!errorInfo && isHttpStatusSuccess(statusCode)) { await invalidateBatchesQuery(); onShowResultDialog({ open: true, content: t("voteElection.election.success"), }); handleCloseDialog(false); onOpenChange(false); } else { showError( createErrorWithFallback( { errorInfo, statusCode, }, { details: ticketId || undefined, showErrorCode: true } ) ); } }; const candidates = (!isLoading && data?.data?.votingCandidates) || []; const [setCandidate, { add, has, remove, reset }] = useSet(new Set<number>()); // Initialize checkboxes for candidates with existing votes (ballotCounter > 0) useUpdateEffect(() => { if (!isLoading && candidates.length > 0) { // Clear existing selections first reset(); // Calculate candidates with votes > 0 const candidatesWithVotes = candidates.filter( candidate => candidate.ballotCounter > 0 ); if (candidatesWithVotes.length > 0) { // Calculate average votes per candidate with votes const avgVotesPerCandidate = Math.floor( totalLimit / candidatesWithVotes.length ); // Only check candidates whose ballotCounter equals the average candidates.forEach(candidate => { if (candidate.ballotCounter === avgVotesPerCandidate) { add(candidate.candidateId); } }); } // Initialize form values with existing ballotCounter values (preserve actual data) const formValues: Record<string, number> = {}; candidates.forEach(candidate => { formValues[candidate.candidateId.toString()] = candidate.ballotCounter || 0; }); form.setValue("candidates", formValues); } }, [candidates, isLoading, totalLimit]); // Handle manual checkbox changes by user interaction const handleCheckboxChange = (candidateId: number, checked: boolean) => { // compute previous size before mutating the set to avoid off-by-one const prevSize = setCandidate.size; if (checked) { add(candidateId); } else { remove(candidateId); } // Calculate new selection set size after the change const newSelectionSize = checked ? prevSize + 1 : Math.max(0, prevSize - 1); if (newSelectionSize > 0) { const avgVotes = Math.floor(totalLimit / newSelectionSize); const formValues: Record<string, number> = {}; candidates.forEach(candidate => { const willBeSelected = candidate.candidateId === candidateId ? checked : has(candidate.candidateId); formValues[candidate.candidateId.toString()] = willBeSelected ? avgVotes : 0; }); form.setValue("candidates", formValues); } else { // Reset all values to 0 when no candidates are selected const formValues: Record<string, number> = {}; candidates.forEach(candidate => { formValues[candidate.candidateId.toString()] = 0; }); form.setValue("candidates", formValues); } }; return ( <Form {...form}> {!isLoading ? ( data?.data ? ( <div className="flex flex-col gap-3.5 px-4 pt-2.5 pb-3"> <div className="flex flex-col gap-2"> <h2 className="responsive-text text-primary font-semibold text-left"> {t("voteElection.election.numElectionInfo")} </h2> <div className="flex max-sm:overflow-x-auto max-sm:overflow-y-hidden gap-3 pb-1"> <div className="max-sm:flex-shrink-0 max-w-40 xs:max-w-52 sm:max-w-64 lg:max-w-none"> <NumberOfShares title={t("shares.shareQtyConfirm")} count={ data?.data.candidateShareholder?.shareQtyConfirm || 0 } /> </div> <div className="max-sm:flex-shrink-0 max-w-40 xs:max-w-52 sm:max-w-64 lg:max-w-none"> <NumberOfShares title={t("shares.candidateCounter")} count={ data?.data.candidateShareholder?.candidateCounter || 0 } /> </div> <div className="max-sm:flex-shrink-0 max-w-40 xs:max-w-52 sm:max-w-64 lg:max-w-none"> <NumberOfShares title={t("shares.totalVotes")} count={data?.data.candidateShareholder?.totalVotes || 0} /> </div> </div> </div> <div className="flex flex-col gap-2 mb-4 rounded-lg"> <h2 className="responsive-text text-primary font-semibold text-left"> {t("voteElection.election.infoCandidate")} </h2> <Table className="text-left"> <TableHeader> <TableRow> <TableHead> {t("voteElection.election.table.candidateName", { tag: isMobileScreen ? <br key="br-1" /> : "", })} </TableHead> <TableHead className="text-center cursor-pointer" onClick={() => { if (setCandidate.size) { // Clear selection and reset form values reset(); form.reset(); } else { // Select all and distribute average votes candidates.forEach(i => add(i.candidateId)); const avgVotes = Math.floor( totalLimit / Math.max(1, candidates.length) ); const values: Record<string, number> = {}; candidates.forEach(c => { values[c.candidateId.toString()] = avgVotes; }); form.setValue("candidates", values); } }} > {t("voteElection.election.table.cumulativeVoting")}{" "} {isMobileScreen && <br key="br-2" />} </TableHead> <TableHead> {t("voteElection.election.table.numberVotes")} </TableHead> </TableRow> </TableHeader> <TableBody> {candidates.map(i => ( <TableRow key={i.candidateId}> <TableCell>{i.fullName}</TableCell> <TableCell className="text-center"> <Checkbox disabled={isDisabled} onCheckedChange={checked => { handleCheckboxChange( i.candidateId, checked === true ); }} checked={has(i.candidateId)} /> </TableCell> <TableCell> <FormField control={form.control} name={`candidates.${i.candidateId}`} render={({ field }) => ( <FormItem> <FormControl> <NumericFormat customInput={Input} placeholder={t( "voteElection.election.table.placeholder" )} className="px-3! py-1.5! min-w-28" disabled={!!setCandidate.size || isDisabled} value={field.value || ""} onValueChange={values => { const { floatValue } = values; field.onChange(floatValue || 0); }} thousandSeparator="," allowNegative={false} decimalScale={0} isAllowed={values => { const { floatValue } = values; return ( floatValue === undefined || floatValue >= 0 ); }} /> </FormControl> </FormItem> )} /> </TableCell> </TableRow> ))} </TableBody> <TableFooter> <TableRow> <TableCell className="text-foreground-05" colSpan={2}> {t("voteElection.election.table.totalVotes")} </TableCell> <TableCell className="text-foreground-05"> {totalVoted} </TableCell> </TableRow> </TableFooter> </Table> </div> </div> ) : ( <ErrorContent errorInfo={data?.errorInfo} className="m-4" /> ) ) : ( <div className="relative min-h-[400px]"> <Skeleton className="h-full rounded-default" /> <LoadingOverlay visible={isLoading} /> </div> )} <VotingElectionFooter onSubmit={onSubmit} isDisabled={isDisabled || isDialogOpen} batchId={data?.data?.batchId} batchName={ language === "vi" ? data?.data?.batchName : data?.data?.batchNameEn } batchType={data?.data?.batchType} /> {showOtpDialog && ( <Suspense fallback={<LoadingOverlay visible={true} />}> <LazyOtpVerificationDialog open={isDialogOpen} onOpenChange={handleCloseDialog} onConfirm={mutationForm} /> </Suspense> )} <CommonDialog open={dialog.open} onOpenChange={(open: boolean) => { if (!open && !dialog.isSuccess) form.clearErrors(); if (!open && dialog.isSuccess && dialog.type !== "none") onOpenChange(false); setDialog({ ...dialog, open }); }} rightButton={ dialog.isSuccess && dialog.type === "none" ? ( <Button className="flex-1" onClick={handleVerifyOtpBeforeSubmit}> {t("voteElection.confirm")} </Button> ) : null } content={dialog.message} iconType={dialog.type} className="md:max-w-sm!" /> </Form> ); }; const VotingItem = ({ item, index, form, length, isDisabled, }: { item: IssuesVoteDTO; index: number; form: UseFormReturn<VotingFormValues>; length: number; isDisabled: boolean; }) => { const { i18n, t } = useTranslation(["overview"]); const language = i18n.language as Language; const { content, description } = getLocalizedProperties( item, ["content", "description"], language ); return ( <div className={cn( "grid grid-cols-1 sm:grid-cols-2 gap-y-2 p-2.5 rounded-none bg-background-06", index < length - 1 && "border-b-2 border-background" )} > <div className="text-foreground-05 text-left flex flex-col gap-1.5"> <h3 className="responsive-text font-semibold">{content}</h3> <p className="responsive-text-sm">{description}</p> </div> <FormField disabled={isDisabled} control={form.control} name={`individualVotes.${item.issueId}`} render={({ field }) => ( <FormItem> <FormControl> <RadioGroup value={field.value} onValueChange={value => { // Clear batch vote when individual vote is changed form.setValue("batchVote", VOTE_OPTIONS.NONE); field.onChange(value); }} className="grid grid-cols-3 gap-5 justify-items-start sm:justify-items-end content-start" > <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.AGREE} id={`approve-${item.issueId}`} /> <Label htmlFor={`approve-${item.issueId}`} className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.agree")} </Label> </div> <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.DISAGREE} id={`disapprove-${item.issueId}`} /> <Label htmlFor={`disapprove-${item.issueId}`} className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.disagree")} </Label> </div> <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.NOIDEA} id={`abstain-${item.issueId}`} /> <Label htmlFor={`abstain-${item.issueId}`} className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.noOption")} </Label> </div> </RadioGroup> </FormControl> </FormItem> )} /> </div> ); }; const VotingElectionFooter = ({ onSubmit, isDisabled, batchId, batchName, batchType, }: { onSubmit?: ( e?: BaseSyntheticEvent<object, any, any> | undefined ) => Promise<void>; isDisabled: boolean; batchId?: string; batchName?: string; batchType?: BatchType; }) => { const form = useFormContext(); const [isLoading, setIsLoading] = useState(false); const { showError } = useError(); const isSubmitting = form.formState.isSubmitting; const { t } = useTranslation(["overview"]); const { i18n } = useTranslation(); const language = i18n.language as Language; const queryClient = useQueryClient(); const [dialog, setDialog] = useState<{ open: boolean; data: MeetingInfoDTO[]; }>({ data: [], open: false, }); const handleFetchGuide = async () => { if (!batchId) return alert("Missing batchId"); const data = await queryClient.fetchQuery( informationQueryOptions(INFO_TYPES.TAILIEU, batchId) ); setDialog({ open: true, data: data.data || [] }); }; const handleDownloadPdf = async (batchId?: string) => { try { if (!batchId) { alert("Missing batchId"); return; } setIsLoading(true); const isVoting = batchType === "BIEUQUYET"; const blobData = await queryClient.fetchQuery( isVoting ? downloadVotingPdf(batchId) : downloadElectionPdf(batchId) ); // The API returns Uint8Array for non-JSON responses (PDFs, etc.) const blob = new Blob([blobData], { type: "application/pdf", }); const blobUrl = URL.createObjectURL(blob); // Create a temporary anchor element to trigger download const link = document.createElement("a"); link.href = blobUrl; const linkName = language === "vi" ? isVoting ? "Giấy biểu quyết" : "Giấy bầu cử" : isVoting ? "Voting form" : "Election form"; link.download = `${linkName} ${batchName}.pdf`; document.body.appendChild(link); link.click(); // Cleanup document.body.removeChild(link); URL.revokeObjectURL(blobUrl); } catch (error) { showError( createErrorWithFallback( error as { errorInfo?: { errorCode?: string; errorMessageVI?: string; errorMessageEN?: string; }; statusCode?: number; }, { showErrorCode: true, } ) ); } finally { setIsLoading(false); } }; return ( <div className="flex flex-col xs:flex-row gap-x-6 gap-y-3 px-4 pb-3"> <div className="flex gap-3 items-center"> <Button type="button" variant="secondary" className="max-h-8 xs:max-h-[unset]" disabled={isLoading} onClick={() => handleDownloadPdf(batchId)} > {!isLoading ? <DownloadIcon /> : <Loader className="animate-spin" />} {t("voteElection.downloadBallot")} </Button> <Button type="button" variant="secondary" className="max-h-8 xs:max-h-[unset]" onClick={handleFetchGuide} > <DocumentIcon className="[&>path]:stroke-primary" /> {t("voteElection.manual")} </Button> </div> {onSubmit && ( <Button type="submit" onClick={onSubmit} className="xs:flex-1" disabled={isDisabled || isSubmitting} > {isSubmitting ? ( <div className="flex items-center justify-center gap-2"> <Loader className="animate-spin flex-1" /> <span>{t("voteElection.confirm") + "..."}</span> </div> ) : ( t("voteElection.confirm") )} </Button> )} <LazyMeetingDocumentDialog open={dialog.open} onOpenChange={open => { if (!open) setDialog({ ...dialog, open }); }} meetingInfos={dialog.data} /> </div> ); }; export default Voting2343243import CommonDialog, { type IconType } from "@/components/dialog"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { Form, FormControl, FormField, FormItem } from "@/components/ui/form"; import { Drawer, DrawerClose, DrawerContent, DrawerHeader, DrawerTitle, } from "@/components/ui/drawer"; import DocumentIcon from "@/icons/document-icon"; import DownloadIcon from "@/icons/download-icon"; import HourglassEagerIcon from "@/icons/hourglass-eager-icon"; import { cn } from "@/lib/utils"; import { useForm, useFormContext, useWatch, type UseFormReturn, } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import type { BaseSyntheticEvent } from "react"; import { lazy, Suspense, useEffect, useState } from "react"; import NumberOfShares from "./number-of-shares"; import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { useSet, useUpdateEffect } from "react-use"; import { ScrollArea } from "@/components/ui/scroll-area"; import { NumericFormat } from "react-number-format"; import type { BatchCandidatesResponse, BatchIssuesResponse, IssuesBatchResponse, IssuesVoteDTO, MeetingInfoDTO, } from "@/types/information.type"; import { BATCH_TYPES, INFO_TYPES, VOTE_OPTIONS, type BatchType, type VoteOption, type VoteStatus, } from "@/constants/params"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { candidatesByBatchIdQueryOptions, informationQueryOptions, issuesByBatchIdQueryOptions, } from "@/features/information/information.api"; import { getLocalizedProperties, isHttpStatusSuccess } from "@/lib/helper"; import { useTranslation } from "react-i18next"; import type { Language } from "@/constants/language"; import { format } from "date-fns"; import { TIME_FORMAT } from "@/constants/format"; import type { ApiResponse } from "@/types/api.type"; import { Skeleton } from "@/components/ui/skeleton"; import { LoadingOverlay } from "@/components/ui/loading-overlay"; import { useMediaQuery } from "react-responsive"; import CancelIcon from "@/icons/cancel-icon"; import NoContent from "@/components/no-content"; import ErrorContent from "@/components/error-content"; import { downloadElectionPdf, downloadVotingPdf, voteCandidatesOptions, voteIssuesOptions, } from "@/features/meeting/meeting.api"; import { useError } from "@/contexts/error-provider"; import { createErrorWithFallback } from "@/lib/error-utils"; import type { RefetchProps } from "../$meetId/-components/batch-list"; import useAuthApi from "@/features/auth/auth.api"; import { Loader } from "lucide-react"; const LazyMeetingDocumentDialog = lazy( () => import("@/components/meeting-document-dialog") ); const LazyOtpVerificationDialog = lazy(() => import("@/components/otp-dialog")); interface IProps { open: boolean; onOpenChange: (open: boolean) => void; batch: IssuesBatchResponse; onShowResultDialog: (dialog: { open: boolean; content: string }) => void; refetchBatches: RefetchProps; } type DialogType = { open: boolean; isSuccess: boolean; message: React.ReactNode; type: IconType; }; export function getBadgeColorByStatus(status: VoteStatus) { switch (status) { case "CHOBOPHIEU": return "bg-primary-03 text-white"; case "DABOPHIEU": return "bg-primary text-white"; default: return "bg-background-07 text-foreground-09 [&_svg_path]:!fill-[#c5c5c5] [&_svg_path]:!stroke-[#c5c5c5]"; } } // Function to create election schema with dynamic total limit const createElectionSchema = (totalLimit: number) => z .object({ candidates: z.record( z.string(), z.number().min(0, "voteElection.election.error.minVote") ), }) .refine( data => { const total = Object.values(data.candidates).reduce( (sum, votes) => sum + votes, 0 ); return total <= totalLimit; }, { message: "voteElection.election.error.maxVote", path: ["candidates"], } ); type ElectionFormValues = z.infer<ReturnType<typeof createElectionSchema>>; // Form schema for voting const votingSchema = z.object({ batchVote: z .enum([ VOTE_OPTIONS.AGREE, VOTE_OPTIONS.DISAGREE, VOTE_OPTIONS.NOIDEA, VOTE_OPTIONS.NONE, VOTE_OPTIONS.INVALID, ]) .optional(), individualVotes: z .record( z.string(), z.enum([ VOTE_OPTIONS.AGREE, VOTE_OPTIONS.DISAGREE, VOTE_OPTIONS.NOIDEA, VOTE_OPTIONS.NONE, VOTE_OPTIONS.INVALID, ]) ) .refine( record => { const selectedValues = Object.values(record); // Return expected result return selectedValues.every(value => value !== VOTE_OPTIONS.NONE); }, { error: "voteElection.vote.error" } ), }); type VotingFormValues = z.infer<typeof votingSchema>; const VotingElectionDialog = ({ open, onOpenChange, batch, onShowResultDialog, refetchBatches, }: IProps) => { const isElection = batch.batchType !== BATCH_TYPES.BIEUQUYET; const isMobile = useMediaQuery({ maxWidth: 768 }); // Use separate queries for different batch types - only run when dialog is actually open const issuesOptions = issuesByBatchIdQueryOptions(batch.batchId); const issuesQuery = useQuery({ ...issuesOptions, enabled: !isElection && open, queryKey: [...issuesOptions.queryKey, String(open)], // Refetch when dialog opens refetchOnMount: false, }); const candidatesOptions = candidatesByBatchIdQueryOptions(batch.batchId); const candidatesQuery = useQuery({ ...candidatesOptions, enabled: isElection && open, queryKey: [...candidatesOptions.queryKey, String(open)], // Refetch when dialog opens refetchOnMount: false, }); const { i18n } = useTranslation(); const language = i18n.language as Language; const data = isElection ? candidatesQuery.data : issuesQuery.data; const isLoading = isElection ? candidatesQuery.isLoading : issuesQuery.isLoading; const { batchName } = getLocalizedProperties(batch, ["batchName"], language); const contentComponent = !isElection ? ( <VotingContent onOpenChange={onOpenChange} isLoading={isLoading} data={data as ApiResponse<BatchIssuesResponse | null>} onShowResultDialog={onShowResultDialog} invalidateBatchesQuery={refetchBatches} /> ) : ( <ElectionContent onOpenChange={onOpenChange} data={data as ApiResponse<BatchCandidatesResponse | null>} isLoading={isLoading} onShowResultDialog={onShowResultDialog} invalidateBatchesQuery={refetchBatches} /> ); const headerComponent = <VotingElectionHeader batch={batch} />; if (isMobile) { return ( <Drawer open={open} onOpenChange={onOpenChange}> <DrawerContent className="max-h-[90vh]!"> <DrawerHeader className="border-b-2 border-background-02 gap-2"> <div className="flex items-center justify-between"> <DrawerTitle className="text-left text-foreground-12 font-bold responsive-text-3xl"> {batchName} </DrawerTitle> <DrawerClose asChild> <Button variant="ghost" className="size-6 p-0!"> <CancelIcon className="size-6" /> <span className="sr-only">Close</span> </Button> </DrawerClose> </div> {headerComponent} </DrawerHeader> <div className="flex-1 overflow-auto">{contentComponent}</div> </DrawerContent> </Drawer> ); } return ( <CommonDialog noTrans title={batchName} content={contentComponent} open={open} className={cn( "max-w-[360px] xs:max-w-[600px] sm:max-w-[800px] gap-0", "p-0! [&>[data-slot='dialog-header']]:p-4 [&>[data-slot='dialog-header']]:border-b-2 [&>[data-slot='dialog-header']]:border-background-02 [&>[data-slot='dialog-close']]:top-4 [&>[data-slot='dialog-close']]:right-4" )} showCloseBtn={false} onOpenChange={onOpenChange} header={headerComponent} /> ); }; const VotingElectionHeader = ({ batch }: { batch: IssuesBatchResponse }) => { const { t } = useTranslation(["overview"]); return ( <div className="flex items-stretch justify-between"> <Badge variant="outlineDestructive" className="h-[unset]"> <HourglassEagerIcon className="!size-4" /> {t("voteElection.endTime", { endTime: batch.endTime && format(batch.endTime, TIME_FORMAT), })} </Badge> <Button isSmall variant="secondary" className={cn( "max-h-8 p-1.5 responsive-text-sm", getBadgeColorByStatus(batch.votingStatus) )} > {batch.votingStatus == "CHOBOPHIEU" ? t("voteElection.status.waiting") : batch.votingStatus == "DABOPHIEU" ? t("voteElection.status.voted") : t("voteElection.status.closed")} </Button> </div> ); }; const VotingContent = ({ onOpenChange, isLoading, data, onShowResultDialog, invalidateBatchesQuery, }: { onOpenChange: (open: boolean) => void; isLoading: boolean; data: ApiResponse<BatchIssuesResponse | null>; onShowResultDialog: (open: { open: boolean; content: string }) => void; invalidateBatchesQuery: RefetchProps; }) => { const { sendOtpVerify } = useAuthApi(); const { showError } = useError(); const { t, i18n } = useTranslation(["overview"]); const [showOtpDialog, setShowOtpDialog] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false); const language = i18n.language as Language; const form = useForm<VotingFormValues>({ resolver: zodResolver(votingSchema), defaultValues: { batchVote: VOTE_OPTIONS.NONE, individualVotes: {}, }, }); const isVerifyOtp = data?.data?.isRequestOtp; const isDisabled = !data?.data?.isAllowedToVote; const handleOpenDialog = () => { setShowOtpDialog(true); setIsDialogOpen(true); }; const handleCloseDialog = (open: boolean) => { setIsDialogOpen(open); }; // Side effect to initialize form values when data changes useEffect(() => { if (data?.data?.votingIssues) { const initialVotes = data.data.votingIssues.reduce< Record<string, VoteOption> >((acc, cur) => { acc[cur.issueId.toString()] = cur.eVote; return acc; }, {}); // Determine initial batch vote const votes = Object.values(initialVotes); const initialBatchVote = votes.length > 0 && votes.every(vote => vote === votes[0] && vote !== VOTE_OPTIONS.NONE) ? votes[0] : VOTE_OPTIONS.NONE; // Update form values form.setValue("individualVotes", initialVotes); form.setValue("batchVote", initialBatchVote); } }, [data?.data?.votingIssues, form]); const addVoteIssuesMutation = useMutation(voteIssuesOptions()); const [dialog, setDialog] = useState<DialogType>({ open: false, isSuccess: true, message: "", type: "none", }); const handleSubmit = async (params: VotingFormValues) => { // Handle voting form submission console.log("Voting data:", params); if (isVerifyOtp) { sendOtpVerify.mutate(undefined, { onSuccess: function ({ statusCode, ticketId, errorInfo }) { if (isHttpStatusSuccess(statusCode)) { handleOpenDialog(); } else { showError( createErrorWithFallback( { errorInfo, statusCode, }, { details: ticketId || undefined, showErrorCode: true } ) ); } }, }); return; } mutationForm(); }; const onSubmit = form.handleSubmit(handleSubmit); const mutationForm = async (otp?: string) => { if (!data.data) return; const { errorInfo, statusCode, ticketId } = await addVoteIssuesMutation.mutateAsync({ batchId: data.data.batchId, shareQtyConfirm: data.data.shareQtyConfirm, issues: Object.entries(form.getValues().individualVotes).map( ([issueId, vote]) => ({ issueId: Number(issueId), eVote: vote, }) ), otp, }); if (!errorInfo && isHttpStatusSuccess(statusCode)) { await invalidateBatchesQuery(); onShowResultDialog({ open: true, content: t("voteElection.vote.success"), }); handleCloseDialog(false); onOpenChange(false); } else { showError( createErrorWithFallback( { errorInfo, statusCode, }, { details: ticketId || undefined, showErrorCode: true } ) ); } }; // Watch for batchVote changes and update all individual votes const batchVote = useWatch({ control: form.control, name: "batchVote" }); useEffect(() => { // Only update individual votes when batchVote has a valid value (not empty string) if (batchVote !== VOTE_OPTIONS.NONE && batchVote !== undefined) { const newIndividualVotes = data.data?.votingIssues?.reduce( (acc, item) => { acc[item.issueId.toString()] = batchVote; return acc; }, {} as Record<string, VoteOption> ); if (newIndividualVotes) { form.setValue("individualVotes", newIndividualVotes); } } }, [batchVote, form]); // Handle form errors useEffect(() => { if (Object.keys(form.formState.errors).length > 0) { // Handle errors here const errors = Object.values(form.formState.errors); const message = errors.map((err, i) => { const code = err.message?.toString() || err.root?.message; return ( <p key={i}> {errors.length > 1 && "• "} {t(code)} </p> ); }); setDialog({ open: true, message, isSuccess: false, type: "error" }); } }, [form.formState.errors]); return ( <Form {...form}> <div className="px-4 py-3.5"> <div className="flex flex-col gap-3"> {/* Bỏ phiếu hàng loạt */} <div className="grid grid-cols-1 gap-y-2 xs:grid-cols-3 sm:grid-cols-2 px-2.5"> <h2 className="responsive-text text-primary font-semibold text-left"> {t("voteElection.vote.title")} </h2> <FormField disabled={isDisabled} control={form.control} name="batchVote" render={({ field }) => ( <FormItem className="xs:col-span-2 sm:col-span-1"> <FormControl> <RadioGroup value={field.value} onValueChange={field.onChange} className="grid grid-cols-3 gap-5 justify-items-start xs:justify-items-end" > <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.AGREE} id="batch-approve" /> <Label htmlFor="batch-approve" className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.agree")} </Label> </div> <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.DISAGREE} id="batch-disapprove" /> <Label htmlFor="batch-disapprove" className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.disagree")} </Label> </div> <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.NOIDEA} id="batch-abstain" /> <Label htmlFor="batch-abstain" className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.noOption")} </Label> </div> </RadioGroup> </FormControl> </FormItem> )} /> </div> {/* Bỏ phiếu từng biểu quyết */} {!isLoading ? ( data?.data ? ( <ScrollArea className="rounded-default"> {data.data.votingIssues ? ( data.data.votingIssues?.map((item, index) => ( <VotingItem key={item.issueId} item={item} isDisabled={isDisabled} length={data.data?.votingIssues?.length || 0} index={index} form={form} /> )) ) : ( <NoContent /> )} </ScrollArea> ) : ( <ErrorContent errorInfo={data?.errorInfo} className="m-4" /> ) ) : ( <div className="relative min-h-[150px]"> <Skeleton className="h-full rounded-default" /> <LoadingOverlay visible={isLoading} /> </div> )} </div> </div> <VotingElectionFooter onSubmit={onSubmit} isDisabled={isDisabled || isDialogOpen} batchId={data?.data?.batchId} batchName={ language === "vi" ? data?.data?.batchName : data?.data?.batchNameEn } batchType={data?.data?.batchType} /> <CommonDialog className="md:max-w-sm!" open={dialog.open} onOpenChange={(open: boolean) => { if (!open && !dialog.isSuccess) form.clearErrors(); if (!open && dialog.isSuccess && dialog.type !== "none") onOpenChange(false); setDialog({ ...dialog, open }); }} content={dialog.message} iconType={dialog.isSuccess ? "success" : "error"} /> {showOtpDialog && ( <Suspense fallback={<LoadingOverlay visible={true} />}> <LazyOtpVerificationDialog open={isDialogOpen} onOpenChange={handleCloseDialog} onConfirm={mutationForm} /> </Suspense> )} </Form> ); }; const ElectionContent = ({ onOpenChange, data, isLoading, onShowResultDialog, invalidateBatchesQuery, }: { onOpenChange: (open: boolean) => void; data: ApiResponse<BatchCandidatesResponse | null>; isLoading: boolean; onShowResultDialog: (open: { open: boolean; content: string }) => void; invalidateBatchesQuery: RefetchProps; }) => { const { sendOtpVerify } = useAuthApi(); const { showError } = useError(); const { t, i18n } = useTranslation(["overview"]); const [showOtpDialog, setShowOtpDialog] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false); const language = i18n.language as Language; const isVerifyOtp = data?.data?.isRequestOtp; // Safe access to data with loading check const totalLimit = (!isLoading && data?.data?.candidateShareholder?.totalVotes) || 0; const isDisabled = !data?.data?.isAllowedToVote; // Create dynamic schema with the total limit from API const dynamicElectionSchema = createElectionSchema(totalLimit); const form = useForm<ElectionFormValues>({ resolver: zodResolver(dynamicElectionSchema), defaultValues: { candidates: {}, }, }); const handleOpenDialog = () => { setShowOtpDialog(true); setIsDialogOpen(true); }; const handleCloseDialog = (open: boolean) => { setIsDialogOpen(open); }; // Side effect to initialize form values when data changes useEffect(() => { if (!isLoading && data?.data?.votingCandidates) { const initialValues: Record<string, number> = {}; data.data.votingCandidates.forEach(candidate => { initialValues[candidate.candidateId.toString()] = candidate.ballotCounter || 0; }); form.setValue("candidates", initialValues); } }, [data?.data?.votingCandidates, isLoading]); const addVoteCandidatesMutation = useMutation(voteCandidatesOptions()); const [dialog, setDialog] = useState<DialogType>({ open: false, isSuccess: true, message: "", type: "none", }); const isMobileScreen = useMediaQuery({ maxWidth: 480 }); // Watch candidates form values and calculate total voted dynamically using useWatch (form.watch doesn't work) const watchedCandidates = useWatch({ control: form.control, name: "candidates", }) as Record<string, number> | undefined; const totalVoted = Object.values(watchedCandidates || {}).reduce( (sum, v) => sum + (typeof v === "number" ? v : Number(v || 0)), 0 ); const handleSubmit = async (data: ElectionFormValues) => { // Handle election form submission console.log("Election data:", data); if (totalVoted <= 0) { setDialog({ message: t("voteElection.election.error.minVote"), open: true, type: "error", isSuccess: false, }); return; } if (totalVoted < totalLimit) { setDialog({ message: t("voteElection.election.error.remainVote", { remaining: totalLimit - totalVoted, }), open: true, type: "none", isSuccess: true, }); return; } handleVerifyOtpBeforeSubmit(); }; const handleVerifyOtpBeforeSubmit = () => { if (isVerifyOtp) { sendOtpVerify.mutate(undefined, { onSuccess: function ({ statusCode, ticketId, errorInfo }) { if (isHttpStatusSuccess(statusCode)) { handleOpenDialog(); } else { showError( createErrorWithFallback( { errorInfo, statusCode, }, { details: ticketId || undefined, showErrorCode: true } ) ); } }, }); return; } mutationForm(); }; const onSubmit = form.handleSubmit(handleSubmit); const mutationForm = async (otp?: string) => { if (!data.data) return; const formData = form.getValues(); const { errorInfo, statusCode, ticketId } = await addVoteCandidatesMutation.mutateAsync({ batchId: data.data.batchId, batchType: data.data.batchType, candidates: Object.entries(formData.candidates).map( ([candidateId, ballotCounter]) => ({ candidateId: Number(candidateId), ballotCounter, }) ), otp, }); if (!errorInfo && isHttpStatusSuccess(statusCode)) { await invalidateBatchesQuery(); onShowResultDialog({ open: true, content: t("voteElection.election.success"), }); handleCloseDialog(false); onOpenChange(false); } else { showError( createErrorWithFallback( { errorInfo, statusCode, }, { details: ticketId || undefined, showErrorCode: true } ) ); } }; const candidates = (!isLoading && data?.data?.votingCandidates) || []; const [setCandidate, { add, has, remove, reset }] = useSet(new Set<number>()); // Initialize checkboxes for candidates with existing votes (ballotCounter > 0) useUpdateEffect(() => { if (!isLoading && candidates.length > 0) { // Clear existing selections first reset(); // Calculate candidates with votes > 0 const candidatesWithVotes = candidates.filter( candidate => candidate.ballotCounter > 0 ); if (candidatesWithVotes.length > 0) { // Calculate average votes per candidate with votes const avgVotesPerCandidate = Math.floor( totalLimit / candidatesWithVotes.length ); // Only check candidates whose ballotCounter equals the average candidates.forEach(candidate => { if (candidate.ballotCounter === avgVotesPerCandidate) { add(candidate.candidateId); } }); } // Initialize form values with existing ballotCounter values (preserve actual data) const formValues: Record<string, number> = {}; candidates.forEach(candidate => { formValues[candidate.candidateId.toString()] = candidate.ballotCounter || 0; }); form.setValue("candidates", formValues); } }, [candidates, isLoading, totalLimit]); // Handle manual checkbox changes by user interaction const handleCheckboxChange = (candidateId: number, checked: boolean) => { // compute previous size before mutating the set to avoid off-by-one const prevSize = setCandidate.size; if (checked) { add(candidateId); } else { remove(candidateId); } // Calculate new selection set size after the change const newSelectionSize = checked ? prevSize + 1 : Math.max(0, prevSize - 1); if (newSelectionSize > 0) { const avgVotes = Math.floor(totalLimit / newSelectionSize); const formValues: Record<string, number> = {}; candidates.forEach(candidate => { const willBeSelected = candidate.candidateId === candidateId ? checked : has(candidate.candidateId); formValues[candidate.candidateId.toString()] = willBeSelected ? avgVotes : 0; }); form.setValue("candidates", formValues); } else { // Reset all values to 0 when no candidates are selected const formValues: Record<string, number> = {}; candidates.forEach(candidate => { formValues[candidate.candidateId.toString()] = 0; }); form.setValue("candidates", formValues); } }; return ( <Form {...form}> {!isLoading ? ( data?.data ? ( <div className="flex flex-col gap-3.5 px-4 pt-2.5 pb-3"> <div className="flex flex-col gap-2"> <h2 className="responsive-text text-primary font-semibold text-left"> {t("voteElection.election.numElectionInfo")} </h2> <div className="flex max-sm:overflow-x-auto max-sm:overflow-y-hidden gap-3 pb-1"> <div className="max-sm:flex-shrink-0 max-w-40 xs:max-w-52 sm:max-w-64 lg:max-w-none"> <NumberOfShares title={t("shares.shareQtyConfirm")} count={ data?.data.candidateShareholder?.shareQtyConfirm || 0 } /> </div> <div className="max-sm:flex-shrink-0 max-w-40 xs:max-w-52 sm:max-w-64 lg:max-w-none"> <NumberOfShares title={t("shares.candidateCounter")} count={ data?.data.candidateShareholder?.candidateCounter || 0 } /> </div> <div className="max-sm:flex-shrink-0 max-w-40 xs:max-w-52 sm:max-w-64 lg:max-w-none"> <NumberOfShares title={t("shares.totalVotes")} count={data?.data.candidateShareholder?.totalVotes || 0} /> </div> </div> </div> <div className="flex flex-col gap-2 mb-4 rounded-lg"> <h2 className="responsive-text text-primary font-semibold text-left"> {t("voteElection.election.infoCandidate")} </h2> <Table className="text-left"> <TableHeader> <TableRow> <TableHead> {t("voteElection.election.table.candidateName", { tag: isMobileScreen ? <br key="br-1" /> : "", })} </TableHead> <TableHead className="text-center cursor-pointer" onClick={() => { if (setCandidate.size) { // Clear selection and reset form values reset(); form.reset(); } else { // Select all and distribute average votes candidates.forEach(i => add(i.candidateId)); const avgVotes = Math.floor( totalLimit / Math.max(1, candidates.length) ); const values: Record<string, number> = {}; candidates.forEach(c => { values[c.candidateId.toString()] = avgVotes; }); form.setValue("candidates", values); } }} > {t("voteElection.election.table.cumulativeVoting")}{" "} {isMobileScreen && <br key="br-2" />} </TableHead> <TableHead> {t("voteElection.election.table.numberVotes")} </TableHead> </TableRow> </TableHeader> <TableBody> {candidates.map(i => ( <TableRow key={i.candidateId}> <TableCell>{i.fullName}</TableCell> <TableCell className="text-center"> <Checkbox disabled={isDisabled} onCheckedChange={checked => { handleCheckboxChange( i.candidateId, checked === true ); }} checked={has(i.candidateId)} /> </TableCell> <TableCell> <FormField control={form.control} name={`candidates.${i.candidateId}`} render={({ field }) => ( <FormItem> <FormControl> <NumericFormat customInput={Input} placeholder={t( "voteElection.election.table.placeholder" )} className="px-3! py-1.5! min-w-28" disabled={!!setCandidate.size || isDisabled} value={field.value || ""} onValueChange={values => { const { floatValue } = values; field.onChange(floatValue || 0); }} thousandSeparator="," allowNegative={false} decimalScale={0} isAllowed={values => { const { floatValue } = values; return ( floatValue === undefined || floatValue >= 0 ); }} /> </FormControl> </FormItem> )} /> </TableCell> </TableRow> ))} </TableBody> <TableFooter> <TableRow> <TableCell className="text-foreground-05" colSpan={2}> {t("voteElection.election.table.totalVotes")} </TableCell> <TableCell className="text-foreground-05"> {totalVoted} </TableCell> </TableRow> </TableFooter> </Table> </div> </div> ) : ( <ErrorContent errorInfo={data?.errorInfo} className="m-4" /> ) ) : ( <div className="relative min-h-[400px]"> <Skeleton className="h-full rounded-default" /> <LoadingOverlay visible={isLoading} /> </div> )} <VotingElectionFooter onSubmit={onSubmit} isDisabled={isDisabled || isDialogOpen} batchId={data?.data?.batchId} batchName={ language === "vi" ? data?.data?.batchName : data?.data?.batchNameEn } batchType={data?.data?.batchType} /> {showOtpDialog && ( <Suspense fallback={<LoadingOverlay visible={true} />}> <LazyOtpVerificationDialog open={isDialogOpen} onOpenChange={handleCloseDialog} onConfirm={mutationForm} /> </Suspense> )} <CommonDialog open={dialog.open} onOpenChange={(open: boolean) => { if (!open && !dialog.isSuccess) form.clearErrors(); if (!open && dialog.isSuccess && dialog.type !== "none") onOpenChange(false); setDialog({ ...dialog, open }); }} rightButton={ dialog.isSuccess && dialog.type === "none" ? ( <Button className="flex-1" onClick={handleVerifyOtpBeforeSubmit}> {t("voteElection.confirm")} </Button> ) : null } content={dialog.message} iconType={dialog.type} className="md:max-w-sm!" /> </Form> ); }; const VotingItem = ({ item, index, form, length, isDisabled, }: { item: IssuesVoteDTO; index: number; form: UseFormReturn<VotingFormValues>; length: number; isDisabled: boolean; }) => { const { i18n, t } = useTranslation(["overview"]); const language = i18n.language as Language; const { content, description } = getLocalizedProperties( item, ["content", "description"], language ); return ( <div className={cn( "grid grid-cols-1 sm:grid-cols-2 gap-y-2 p-2.5 rounded-none bg-background-06", index < length - 1 && "border-b-2 border-background" )} > <div className="text-foreground-05 text-left flex flex-col gap-1.5"> <h3 className="responsive-text font-semibold">{content}</h3> <p className="responsive-text-sm">{description}</p> </div> <FormField disabled={isDisabled} control={form.control} name={`individualVotes.${item.issueId}`} render={({ field }) => ( <FormItem> <FormControl> <RadioGroup value={field.value} onValueChange={value => { // Clear batch vote when individual vote is changed form.setValue("batchVote", VOTE_OPTIONS.NONE); field.onChange(value); }} className="grid grid-cols-3 gap-5 justify-items-start sm:justify-items-end content-start" > <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.AGREE} id={`approve-${item.issueId}`} /> <Label htmlFor={`approve-${item.issueId}`} className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.agree")} </Label> </div> <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.DISAGREE} id={`disapprove-${item.issueId}`} /> <Label htmlFor={`disapprove-${item.issueId}`} className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.disagree")} </Label> </div> <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.NOIDEA} id={`abstain-${item.issueId}`} /> <Label htmlFor={`abstain-${item.issueId}`} className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.noOption")} </Label> </div> </RadioGroup> </FormControl> </FormItem> )} /> </div> ); }; const VotingElectionFooter = ({ onSubmit, isDisabled, batchId, batchName, batchType, }: { onSubmit?: ( e?: BaseSyntheticEvent<object, any, any> | undefined ) => Promise<void>; isDisabled: boolean; batchId?: string; batchName?: string; batchType?: BatchType; }) => { const form = useFormContext(); const [isLoading, setIsLoading] = useState(false); const { showError } = useError(); const isSubmitting = form.formState.isSubmitting; const { t } = useTranslation(["overview"]); const { i18n } = useTranslation(); const language = i18n.language as Language; const queryClient = useQueryClient(); const [dialog, setDialog] = useState<{ open: boolean; data: MeetingInfoDTO[]; }>({ data: [], open: false, }); const handleFetchGuide = async () => { if (!batchId) return alert("Missing batchId"); const data = await queryClient.fetchQuery( informationQueryOptions(INFO_TYPES.TAILIEU, batchId) ); setDialog({ open: true, data: data.data || [] }); }; const handleDownloadPdf = async (batchId?: string) => { try { if (!batchId) { alert("Missing batchId"); return; } setIsLoading(true); const isVoting = batchType === "BIEUQUYET"; const blobData = await queryClient.fetchQuery( isVoting ? downloadVotingPdf(batchId) : downloadElectionPdf(batchId) ); // The API returns Uint8Array for non-JSON responses (PDFs, etc.) const blob = new Blob([blobData], { type: "application/pdf", }); const blobUrl = URL.createObjectURL(blob); // Create a temporary anchor element to trigger download const link = document.createElement("a"); link.href = blobUrl; const linkName = language === "vi" ? isVoting ? "Giấy biểu quyết" : "Giấy bầu cử" : isVoting ? "Voting form" : "Election form"; link.download = `${linkName} ${batchName}.pdf`; document.body.appendChild(link); link.click(); // Cleanup document.body.removeChild(link); URL.revokeObjectURL(blobUrl); } catch (error) { showError( createErrorWithFallback( error as { errorInfo?: { errorCode?: string; errorMessageVI?: string; errorMessageEN?: string; }; statusCode?: number; }, { showErrorCode: true, } ) ); } finally { setIsLoading(false); } }; return ( <div className="flex flex-col xs:flex-row gap-x-6 gap-y-3 px-4 pb-3"> <div className="flex gap-3 items-center"> <Button type="button" variant="secondary" className="max-h-8 xs:max-h-[unset]" disabled={isLoading} onClick={() => handleDownloadPdf(batchId)} > {!isLoading ? <DownloadIcon /> : <Loader className="animate-spin" />} {t("voteElection.downloadBallot")} </Button> <Button type="button" variant="secondary" className="max-h-8 xs:max-h-[unset]" onClick={handleFetchGuide} > <DocumentIcon className="[&>path]:stroke-primary" /> {t("voteElection.manual")} </Button> </div> {onSubmit && ( <Button type="submit" onClick={onSubmit} className="xs:flex-1" disabled={isDisabled || isSubmitting} > {isSubmitting ? ( <div className="flex items-center justify-center gap-2"> <Loader className="animate-spin flex-1" /> <span>{t("voteElection.confirm") + "..."}</span> </div> ) : ( t("voteElection.confirm") )} </Button> )} <LazyMeetingDocumentDialog open={dialog.open} onOpenChange={open => { if (!open) setDialog({ ...dialog, open }); }} meetingInfos={dialog.data} /> </div> ); }; export default VotingElectionDialog;23423443242import CommonDialog, { type IconType } from "@/components/dialog"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { Form, FormControl, FormField, FormItem } from "@/components/ui/form"; import { Drawer, DrawerClose, DrawerContent, DrawerHeader, DrawerTitle, } from "@/components/ui/drawer"; import DocumentIcon from "@/icons/document-icon"; import DownloadIcon from "@/icons/download-icon"; import HourglassEagerIcon from "@/icons/hourglass-eager-icon"; import { cn } from "@/lib/utils"; import { useForm, useFormContext, useWatch, type UseFormReturn, } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import type { BaseSyntheticEvent } from "react"; import { lazy, Suspense, useEffect, useState } from "react"; import NumberOfShares from "./number-of-shares"; import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { useSet, useUpdateEffect } from "react-use"; import { ScrollArea } from "@/components/ui/scroll-area"; import { NumericFormat } from "react-number-format"; import type { BatchCandidatesResponse, BatchIssuesResponse, IssuesBatchResponse, IssuesVoteDTO, MeetingInfoDTO, } from "@/types/information.type"; import { BATCH_TYPES, INFO_TYPES, VOTE_OPTIONS, type BatchType, type VoteOption, type VoteStatus, } from "@/constants/params"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { candidatesByBatchIdQueryOptions, informationQueryOptions, issuesByBatchIdQueryOptions, } from "@/features/information/information.api"; import { getLocalizedProperties, isHttpStatusSuccess } from "@/lib/helper"; import { useTranslation } from "react-i18next"; import type { Language } from "@/constants/language"; import { format } from "date-fns"; import { TIME_FORMAT } from "@/constants/format"; import type { ApiResponse } from "@/types/api.type"; import { Skeleton } from "@/components/ui/skeleton"; import { LoadingOverlay } from "@/components/ui/loading-overlay"; import { useMediaQuery } from "react-responsive"; import CancelIcon from "@/icons/cancel-icon"; import NoContent from "@/components/no-content"; import ErrorContent from "@/components/error-content"; import { downloadElectionPdf, downloadVotingPdf, voteCandidatesOptions, voteIssuesOptions, } from "@/features/meeting/meeting.api"; import { useError } from "@/contexts/error-provider"; import { createErrorWithFallback } from "@/lib/error-utils"; import type { RefetchProps } from "../$meetId/-components/batch-list"; import useAuthApi from "@/features/auth/auth.api"; import { Loader } from "lucide-react"; const LazyMeetingDocumentDialog = lazy( () => import("@/components/meeting-document-dialog") ); const LazyOtpVerificationDialog = lazy(() => import("@/components/otp-dialog")); interface IProps { open: boolean; onOpenChange: (open: boolean) => void; batch: IssuesBatchResponse; onShowResultDialog: (dialog: { open: boolean; content: string }) => void; refetchBatches: RefetchProps; } type DialogType = { open: boolean; isSuccess: boolean; message: React.ReactNode; type: IconType; }; export function getBadgeColorByStatus(status: VoteStatus) { switch (status) { case "CHOBOPHIEU": return "bg-primary-03 text-white"; case "DABOPHIEU": return "bg-primary text-white"; default: return "bg-background-07 text-foreground-09 [&_svg_path]:!fill-[#c5c5c5] [&_svg_path]:!stroke-[#c5c5c5]"; } } // Function to create election schema with dynamic total limit const createElectionSchema = (totalLimit: number) => z .object({ candidates: z.record( z.string(), z.number().min(0, "voteElection.election.error.minVote") ), }) .refine( data => { const total = Object.values(data.candidates).reduce( (sum, votes) => sum + votes, 0 ); return total <= totalLimit; }, { message: "voteElection.election.error.maxVote", path: ["candidates"], } ); type ElectionFormValues = z.infer<ReturnType<typeof createElectionSchema>>; // Form schema for voting const votingSchema = z.object({ batchVote: z .enum([ VOTE_OPTIONS.AGREE, VOTE_OPTIONS.DISAGREE, VOTE_OPTIONS.NOIDEA, VOTE_OPTIONS.NONE, VOTE_OPTIONS.INVALID, ]) .optional(), individualVotes: z .record( z.string(), z.enum([ VOTE_OPTIONS.AGREE, VOTE_OPTIONS.DISAGREE, VOTE_OPTIONS.NOIDEA, VOTE_OPTIONS.NONE, VOTE_OPTIONS.INVALID, ]) ) .refine( record => { const selectedValues = Object.values(record); // Return expected result return selectedValues.every(value => value !== VOTE_OPTIONS.NONE); }, { error: "voteElection.vote.error" } ), }); type VotingFormValues = z.infer<typeof votingSchema>; const VotingElectionDialog = ({ open, onOpenChange, batch, onShowResultDialog, refetchBatches, }: IProps) => { const isElection = batch.batchType !== BATCH_TYPES.BIEUQUYET; const isMobile = useMediaQuery({ maxWidth: 768 }); // Use separate queries for different batch types - only run when dialog is actually open const issuesOptions = issuesByBatchIdQueryOptions(batch.batchId); const issuesQuery = useQuery({ ...issuesOptions, enabled: !isElection && open, queryKey: [...issuesOptions.queryKey, String(open)], // Refetch when dialog opens refetchOnMount: false, }); const candidatesOptions = candidatesByBatchIdQueryOptions(batch.batchId); const candidatesQuery = useQuery({ ...candidatesOptions, enabled: isElection && open, queryKey: [...candidatesOptions.queryKey, String(open)], // Refetch when dialog opens refetchOnMount: false, }); const { i18n } = useTranslation(); const language = i18n.language as Language; const data = isElection ? candidatesQuery.data : issuesQuery.data; const isLoading = isElection ? candidatesQuery.isLoading : issuesQuery.isLoading; const { batchName } = getLocalizedProperties(batch, ["batchName"], language); const contentComponent = !isElection ? ( <VotingContent onOpenChange={onOpenChange} isLoading={isLoading} data={data as ApiResponse<BatchIssuesResponse | null>} onShowResultDialog={onShowResultDialog} invalidateBatchesQuery={refetchBatches} /> ) : ( <ElectionContent onOpenChange={onOpenChange} data={data as ApiResponse<BatchCandidatesResponse | null>} isLoading={isLoading} onShowResultDialog={onShowResultDialog} invalidateBatchesQuery={refetchBatches} /> ); const headerComponent = <VotingElectionHeader batch={batch} />; if (isMobile) { return ( <Drawer open={open} onOpenChange={onOpenChange}> <DrawerContent className="max-h-[90vh]!"> <DrawerHeader className="border-b-2 border-background-02 gap-2"> <div className="flex items-center justify-between"> <DrawerTitle className="text-left text-foreground-12 font-bold responsive-text-3xl"> {batchName} </DrawerTitle> <DrawerClose asChild> <Button variant="ghost" className="size-6 p-0!"> <CancelIcon className="size-6" /> <span className="sr-only">Close</span> </Button> </DrawerClose> </div> {headerComponent} </DrawerHeader> <div className="flex-1 overflow-auto">{contentComponent}</div> </DrawerContent> </Drawer> ); } return ( <CommonDialog noTrans title={batchName} content={contentComponent} open={open} className={cn( "max-w-[360px] xs:max-w-[600px] sm:max-w-[800px] gap-0", "p-0! [&>[data-slot='dialog-header']]:p-4 [&>[data-slot='dialog-header']]:border-b-2 [&>[data-slot='dialog-header']]:border-background-02 [&>[data-slot='dialog-close']]:top-4 [&>[data-slot='dialog-close']]:right-4" )} showCloseBtn={false} onOpenChange={onOpenChange} header={headerComponent} /> ); }; const VotingElectionHeader = ({ batch }: { batch: IssuesBatchResponse }) => { const { t } = useTranslation(["overview"]); return ( <div className="flex items-stretch justify-between"> <Badge variant="outlineDestructive" className="h-[unset]"> <HourglassEagerIcon className="!size-4" /> {t("voteElection.endTime", { endTime: batch.endTime && format(batch.endTime, TIME_FORMAT), })} </Badge> <Button isSmall variant="secondary" className={cn( "max-h-8 p-1.5 responsive-text-sm", getBadgeColorByStatus(batch.votingStatus) )} > {batch.votingStatus == "CHOBOPHIEU" ? t("voteElection.status.waiting") : batch.votingStatus == "DABOPHIEU" ? t("voteElection.status.voted") : t("voteElection.status.closed")} </Button> </div> ); }; const VotingContent = ({ onOpenChange, isLoading, data, onShowResultDialog, invalidateBatchesQuery, }: { onOpenChange: (open: boolean) => void; isLoading: boolean; data: ApiResponse<BatchIssuesResponse | null>; onShowResultDialog: (open: { open: boolean; content: string }) => void; invalidateBatchesQuery: RefetchProps; }) => { const { sendOtpVerify } = useAuthApi(); const { showError } = useError(); const { t, i18n } = useTranslation(["overview"]); const [showOtpDialog, setShowOtpDialog] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false); const language = i18n.language as Language; const form = useForm<VotingFormValues>({ resolver: zodResolver(votingSchema), defaultValues: { batchVote: VOTE_OPTIONS.NONE, individualVotes: {}, }, }); const isVerifyOtp = data?.data?.isRequestOtp; const isDisabled = !data?.data?.isAllowedToVote; const handleOpenDialog = () => { setShowOtpDialog(true); setIsDialogOpen(true); }; const handleCloseDialog = (open: boolean) => { setIsDialogOpen(open); }; // Side effect to initialize form values when data changes useEffect(() => { if (data?.data?.votingIssues) { const initialVotes = data.data.votingIssues.reduce< Record<string, VoteOption> >((acc, cur) => { acc[cur.issueId.toString()] = cur.eVote; return acc; }, {}); // Determine initial batch vote const votes = Object.values(initialVotes); const initialBatchVote = votes.length > 0 && votes.every(vote => vote === votes[0] && vote !== VOTE_OPTIONS.NONE) ? votes[0] : VOTE_OPTIONS.NONE; // Update form values form.setValue("individualVotes", initialVotes); form.setValue("batchVote", initialBatchVote); } }, [data?.data?.votingIssues, form]); const addVoteIssuesMutation = useMutation(voteIssuesOptions()); const [dialog, setDialog] = useState<DialogType>({ open: false, isSuccess: true, message: "", type: "none", }); const handleSubmit = async (params: VotingFormValues) => { // Handle voting form submission console.log("Voting data:", params); if (isVerifyOtp) { sendOtpVerify.mutate(undefined, { onSuccess: function ({ statusCode, ticketId, errorInfo }) { if (isHttpStatusSuccess(statusCode)) { handleOpenDialog(); } else { showError( createErrorWithFallback( { errorInfo, statusCode, }, { details: ticketId || undefined, showErrorCode: true } ) ); } }, }); return; } mutationForm(); }; const onSubmit = form.handleSubmit(handleSubmit); const mutationForm = async (otp?: string) => { if (!data.data) return; const { errorInfo, statusCode, ticketId } = await addVoteIssuesMutation.mutateAsync({ batchId: data.data.batchId, shareQtyConfirm: data.data.shareQtyConfirm, issues: Object.entries(form.getValues().individualVotes).map( ([issueId, vote]) => ({ issueId: Number(issueId), eVote: vote, }) ), otp, }); if (!errorInfo && isHttpStatusSuccess(statusCode)) { await invalidateBatchesQuery(); onShowResultDialog({ open: true, content: t("voteElection.vote.success"), }); handleCloseDialog(false); onOpenChange(false); } else { showError( createErrorWithFallback( { errorInfo, statusCode, }, { details: ticketId || undefined, showErrorCode: true } ) ); } }; // Watch for batchVote changes and update all individual votes const batchVote = useWatch({ control: form.control, name: "batchVote" }); useEffect(() => { // Only update individual votes when batchVote has a valid value (not empty string) if (batchVote !== VOTE_OPTIONS.NONE && batchVote !== undefined) { const newIndividualVotes = data.data?.votingIssues?.reduce( (acc, item) => { acc[item.issueId.toString()] = batchVote; return acc; }, {} as Record<string, VoteOption> ); if (newIndividualVotes) { form.setValue("individualVotes", newIndividualVotes); } } }, [batchVote, form]); // Handle form errors useEffect(() => { if (Object.keys(form.formState.errors).length > 0) { // Handle errors here const errors = Object.values(form.formState.errors); const message = errors.map((err, i) => { const code = err.message?.toString() || err.root?.message; return ( <p key={i}> {errors.length > 1 && "• "} {t(code)} </p> ); }); setDialog({ open: true, message, isSuccess: false, type: "error" }); } }, [form.formState.errors]); return ( <Form {...form}> <div className="px-4 py-3.5"> <div className="flex flex-col gap-3"> {/* Bỏ phiếu hàng loạt */} <div className="grid grid-cols-1 gap-y-2 xs:grid-cols-3 sm:grid-cols-2 px-2.5"> <h2 className="responsive-text text-primary font-semibold text-left"> {t("voteElection.vote.title")} </h2> <FormField disabled={isDisabled} control={form.control} name="batchVote" render={({ field }) => ( <FormItem className="xs:col-span-2 sm:col-span-1"> <FormControl> <RadioGroup value={field.value} onValueChange={field.onChange} className="grid grid-cols-3 gap-5 justify-items-start xs:justify-items-end" > <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.AGREE} id="batch-approve" /> <Label htmlFor="batch-approve" className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.agree")} </Label> </div> <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.DISAGREE} id="batch-disapprove" /> <Label htmlFor="batch-disapprove" className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.disagree")} </Label> </div> <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.NOIDEA} id="batch-abstain" /> <Label htmlFor="batch-abstain" className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.noOption")} </Label> </div> </RadioGroup> </FormControl> </FormItem> )} /> </div> {/* Bỏ phiếu từng biểu quyết */} {!isLoading ? ( data?.data ? ( <ScrollArea className="rounded-default"> {data.data.votingIssues ? ( data.data.votingIssues?.map((item, index) => ( <VotingItem key={item.issueId} item={item} isDisabled={isDisabled} length={data.data?.votingIssues?.length || 0} index={index} form={form} /> )) ) : ( <NoContent /> )} </ScrollArea> ) : ( <ErrorContent errorInfo={data?.errorInfo} className="m-4" /> ) ) : ( <div className="relative min-h-[150px]"> <Skeleton className="h-full rounded-default" /> <LoadingOverlay visible={isLoading} /> </div> )} </div> </div> <VotingElectionFooter onSubmit={onSubmit} isDisabled={isDisabled || isDialogOpen} batchId={data?.data?.batchId} batchName={ language === "vi" ? data?.data?.batchName : data?.data?.batchNameEn } batchType={data?.data?.batchType} /> <CommonDialog className="md:max-w-sm!" open={dialog.open} onOpenChange={(open: boolean) => { if (!open && !dialog.isSuccess) form.clearErrors(); if (!open && dialog.isSuccess && dialog.type !== "none") onOpenChange(false); setDialog({ ...dialog, open }); }} content={dialog.message} iconType={dialog.isSuccess ? "success" : "error"} /> {showOtpDialog && ( <Suspense fallback={<LoadingOverlay visible={true} />}> <LazyOtpVerificationDialog open={isDialogOpen} onOpenChange={handleCloseDialog} onConfirm={mutationForm} /> </Suspense> )} </Form> ); }; const ElectionContent = ({ onOpenChange, data, isLoading, onShowResultDialog, invalidateBatchesQuery, }: { onOpenChange: (open: boolean) => void; data: ApiResponse<BatchCandidatesResponse | null>; isLoading: boolean; onShowResultDialog: (open: { open: boolean; content: string }) => void; invalidateBatchesQuery: RefetchProps; }) => { const { sendOtpVerify } = useAuthApi(); const { showError } = useError(); const { t, i18n } = useTranslation(["overview"]); const [showOtpDialog, setShowOtpDialog] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false); const language = i18n.language as Language; const isVerifyOtp = data?.data?.isRequestOtp; // Safe access to data with loading check const totalLimit = (!isLoading && data?.data?.candidateShareholder?.totalVotes) || 0; const isDisabled = !data?.data?.isAllowedToVote; // Create dynamic schema with the total limit from API const dynamicElectionSchema = createElectionSchema(totalLimit); const form = useForm<ElectionFormValues>({ resolver: zodResolver(dynamicElectionSchema), defaultValues: { candidates: {}, }, }); const handleOpenDialog = () => { setShowOtpDialog(true); setIsDialogOpen(true); }; const handleCloseDialog = (open: boolean) => { setIsDialogOpen(open); }; // Side effect to initialize form values when data changes useEffect(() => { if (!isLoading && data?.data?.votingCandidates) { const initialValues: Record<string, number> = {}; data.data.votingCandidates.forEach(candidate => { initialValues[candidate.candidateId.toString()] = candidate.ballotCounter || 0; }); form.setValue("candidates", initialValues); } }, [data?.data?.votingCandidates, isLoading]); const addVoteCandidatesMutation = useMutation(voteCandidatesOptions()); const [dialog, setDialog] = useState<DialogType>({ open: false, isSuccess: true, message: "", type: "none", }); const isMobileScreen = useMediaQuery({ maxWidth: 480 }); // Watch candidates form values and calculate total voted dynamically using useWatch (form.watch doesn't work) const watchedCandidates = useWatch({ control: form.control, name: "candidates", }) as Record<string, number> | undefined; const totalVoted = Object.values(watchedCandidates || {}).reduce( (sum, v) => sum + (typeof v === "number" ? v : Number(v || 0)), 0 ); const handleSubmit = async (data: ElectionFormValues) => { // Handle election form submission console.log("Election data:", data); if (totalVoted <= 0) { setDialog({ message: t("voteElection.election.error.minVote"), open: true, type: "error", isSuccess: false, }); return; } if (totalVoted < totalLimit) { setDialog({ message: t("voteElection.election.error.remainVote", { remaining: totalLimit - totalVoted, }), open: true, type: "none", isSuccess: true, }); return; } handleVerifyOtpBeforeSubmit(); }; const handleVerifyOtpBeforeSubmit = () => { if (isVerifyOtp) { sendOtpVerify.mutate(undefined, { onSuccess: function ({ statusCode, ticketId, errorInfo }) { if (isHttpStatusSuccess(statusCode)) { handleOpenDialog(); } else { showError( createErrorWithFallback( { errorInfo, statusCode, }, { details: ticketId || undefined, showErrorCode: true } ) ); } }, }); return; } mutationForm(); }; const onSubmit = form.handleSubmit(handleSubmit); const mutationForm = async (otp?: string) => { if (!data.data) return; const formData = form.getValues(); const { errorInfo, statusCode, ticketId } = await addVoteCandidatesMutation.mutateAsync({ batchId: data.data.batchId, batchType: data.data.batchType, candidates: Object.entries(formData.candidates).map( ([candidateId, ballotCounter]) => ({ candidateId: Number(candidateId), ballotCounter, }) ), otp, }); if (!errorInfo && isHttpStatusSuccess(statusCode)) { await invalidateBatchesQuery(); onShowResultDialog({ open: true, content: t("voteElection.election.success"), }); handleCloseDialog(false); onOpenChange(false); } else { showError( createErrorWithFallback( { errorInfo, statusCode, }, { details: ticketId || undefined, showErrorCode: true } ) ); } }; const candidates = (!isLoading && data?.data?.votingCandidates) || []; const [setCandidate, { add, has, remove, reset }] = useSet(new Set<number>()); // Initialize checkboxes for candidates with existing votes (ballotCounter > 0) useUpdateEffect(() => { if (!isLoading && candidates.length > 0) { // Clear existing selections first reset(); // Calculate candidates with votes > 0 const candidatesWithVotes = candidates.filter( candidate => candidate.ballotCounter > 0 ); if (candidatesWithVotes.length > 0) { // Calculate average votes per candidate with votes const avgVotesPerCandidate = Math.floor( totalLimit / candidatesWithVotes.length ); // Only check candidates whose ballotCounter equals the average candidates.forEach(candidate => { if (candidate.ballotCounter === avgVotesPerCandidate) { add(candidate.candidateId); } }); } // Initialize form values with existing ballotCounter values (preserve actual data) const formValues: Record<string, number> = {}; candidates.forEach(candidate => { formValues[candidate.candidateId.toString()] = candidate.ballotCounter || 0; }); form.setValue("candidates", formValues); } }, [candidates, isLoading, totalLimit]); // Handle manual checkbox changes by user interaction const handleCheckboxChange = (candidateId: number, checked: boolean) => { // compute previous size before mutating the set to avoid off-by-one const prevSize = setCandidate.size; if (checked) { add(candidateId); } else { remove(candidateId); } // Calculate new selection set size after the change const newSelectionSize = checked ? prevSize + 1 : Math.max(0, prevSize - 1); if (newSelectionSize > 0) { const avgVotes = Math.floor(totalLimit / newSelectionSize); const formValues: Record<string, number> = {}; candidates.forEach(candidate => { const willBeSelected = candidate.candidateId === candidateId ? checked : has(candidate.candidateId); formValues[candidate.candidateId.toString()] = willBeSelected ? avgVotes : 0; }); form.setValue("candidates", formValues); } else { // Reset all values to 0 when no candidates are selected const formValues: Record<string, number> = {}; candidates.forEach(candidate => { formValues[candidate.candidateId.toString()] = 0; }); form.setValue("candidates", formValues); } }; return ( <Form {...form}> {!isLoading ? ( data?.data ? ( <div className="flex flex-col gap-3.5 px-4 pt-2.5 pb-3"> <div className="flex flex-col gap-2"> <h2 className="responsive-text text-primary font-semibold text-left"> {t("voteElection.election.numElectionInfo")} </h2> <div className="flex max-sm:overflow-x-auto max-sm:overflow-y-hidden gap-3 pb-1"> <div className="max-sm:flex-shrink-0 max-w-40 xs:max-w-52 sm:max-w-64 lg:max-w-none"> <NumberOfShares title={t("shares.shareQtyConfirm")} count={ data?.data.candidateShareholder?.shareQtyConfirm || 0 } /> </div> <div className="max-sm:flex-shrink-0 max-w-40 xs:max-w-52 sm:max-w-64 lg:max-w-none"> <NumberOfShares title={t("shares.candidateCounter")} count={ data?.data.candidateShareholder?.candidateCounter || 0 } /> </div> <div className="max-sm:flex-shrink-0 max-w-40 xs:max-w-52 sm:max-w-64 lg:max-w-none"> <NumberOfShares title={t("shares.totalVotes")} count={data?.data.candidateShareholder?.totalVotes || 0} /> </div> </div> </div> <div className="flex flex-col gap-2 mb-4 rounded-lg"> <h2 className="responsive-text text-primary font-semibold text-left"> {t("voteElection.election.infoCandidate")} </h2> <Table className="text-left"> <TableHeader> <TableRow> <TableHead> {t("voteElection.election.table.candidateName", { tag: isMobileScreen ? <br key="br-1" /> : "", })} </TableHead> <TableHead className="text-center cursor-pointer" onClick={() => { if (setCandidate.size) { // Clear selection and reset form values reset(); form.reset(); } else { // Select all and distribute average votes candidates.forEach(i => add(i.candidateId)); const avgVotes = Math.floor( totalLimit / Math.max(1, candidates.length) ); const values: Record<string, number> = {}; candidates.forEach(c => { values[c.candidateId.toString()] = avgVotes; }); form.setValue("candidates", values); } }} > {t("voteElection.election.table.cumulativeVoting")}{" "} {isMobileScreen && <br key="br-2" />} </TableHead> <TableHead> {t("voteElection.election.table.numberVotes")} </TableHead> </TableRow> </TableHeader> <TableBody> {candidates.map(i => ( <TableRow key={i.candidateId}> <TableCell>{i.fullName}</TableCell> <TableCell className="text-center"> <Checkbox disabled={isDisabled} onCheckedChange={checked => { handleCheckboxChange( i.candidateId, checked === true ); }} checked={has(i.candidateId)} /> </TableCell> <TableCell> <FormField control={form.control} name={`candidates.${i.candidateId}`} render={({ field }) => ( <FormItem> <FormControl> <NumericFormat customInput={Input} placeholder={t( "voteElection.election.table.placeholder" )} className="px-3! py-1.5! min-w-28" disabled={!!setCandidate.size || isDisabled} value={field.value || ""} onValueChange={values => { const { floatValue } = values; field.onChange(floatValue || 0); }} thousandSeparator="," allowNegative={false} decimalScale={0} isAllowed={values => { const { floatValue } = values; return ( floatValue === undefined || floatValue >= 0 ); }} /> </FormControl> </FormItem> )} /> </TableCell> </TableRow> ))} </TableBody> <TableFooter> <TableRow> <TableCell className="text-foreground-05" colSpan={2}> {t("voteElection.election.table.totalVotes")} </TableCell> <TableCell className="text-foreground-05"> {totalVoted} </TableCell> </TableRow> </TableFooter> </Table> </div> </div> ) : ( <ErrorContent errorInfo={data?.errorInfo} className="m-4" /> ) ) : ( <div className="relative min-h-[400px]"> <Skeleton className="h-full rounded-default" /> <LoadingOverlay visible={isLoading} /> </div> )} <VotingElectionFooter onSubmit={onSubmit} isDisabled={isDisabled || isDialogOpen} batchId={data?.data?.batchId} batchName={ language === "vi" ? data?.data?.batchName : data?.data?.batchNameEn } batchType={data?.data?.batchType} /> {showOtpDialog && ( <Suspense fallback={<LoadingOverlay visible={true} />}> <LazyOtpVerificationDialog open={isDialogOpen} onOpenChange={handleCloseDialog} onConfirm={mutationForm} /> </Suspense> )} <CommonDialog open={dialog.open} onOpenChange={(open: boolean) => { if (!open && !dialog.isSuccess) form.clearErrors(); if (!open && dialog.isSuccess && dialog.type !== "none") onOpenChange(false); setDialog({ ...dialog, open }); }} rightButton={ dialog.isSuccess && dialog.type === "none" ? ( <Button className="flex-1" onClick={handleVerifyOtpBeforeSubmit}> {t("voteElection.confirm")} </Button> ) : null } content={dialog.message} iconType={dialog.type} className="md:max-w-sm!" /> </Form> ); }; const VotingItem = ({ item, index, form, length, isDisabled, }: { item: IssuesVoteDTO; index: number; form: UseFormReturn<VotingFormValues>; length: number; isDisabled: boolean; }) => { const { i18n, t } = useTranslation(["overview"]); const language = i18n.language as Language; const { content, description } = getLocalizedProperties( item, ["content", "description"], language ); return ( <div className={cn( "grid grid-cols-1 sm:grid-cols-2 gap-y-2 p-2.5 rounded-none bg-background-06", index < length - 1 && "border-b-2 border-background" )} > <div className="text-foreground-05 text-left flex flex-col gap-1.5"> <h3 className="responsive-text font-semibold">{content}</h3> <p className="responsive-text-sm">{description}</p> </div> <FormField disabled={isDisabled} control={form.control} name={`individualVotes.${item.issueId}`} render={({ field }) => ( <FormItem> <FormControl> <RadioGroup value={field.value} onValueChange={value => { // Clear batch vote when individual vote is changed form.setValue("batchVote", VOTE_OPTIONS.NONE); field.onChange(value); }} className="grid grid-cols-3 gap-5 justify-items-start sm:justify-items-end content-start" > <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.AGREE} id={`approve-${item.issueId}`} /> <Label htmlFor={`approve-${item.issueId}`} className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.agree")} </Label> </div> <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.DISAGREE} id={`disapprove-${item.issueId}`} /> <Label htmlFor={`disapprove-${item.issueId}`} className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.disagree")} </Label> </div> <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.NOIDEA} id={`abstain-${item.issueId}`} /> <Label htmlFor={`abstain-${item.issueId}`} className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.noOption")} </Label> </div> </RadioGroup> </FormControl> </FormItem> )} /> </div> ); }; const VotingElectionFooter = ({ onSubmit, isDisabled, batchId, batchName, batchType, }: { onSubmit?: ( e?: BaseSyntheticEvent<object, any, any> | undefined ) => Promise<void>; isDisabled: boolean; batchId?: string; batchName?: string; batchType?: BatchType; }) => { const form = useFormContext(); const [isLoading, setIsLoading] = useState(false); const { showError } = useError(); const isSubmitting = form.formState.isSubmitting; const { t } = useTranslation(["overview"]); const { i18n } = useTranslation(); const language = i18n.language as Language; const queryClient = useQueryClient(); const [dialog, setDialog] = useState<{ open: boolean; data: MeetingInfoDTO[]; }>({ data: [], open: false, }); const handleFetchGuide = async () => { if (!batchId) return alert("Missing batchId"); const data = await queryClient.fetchQuery( informationQueryOptions(INFO_TYPES.TAILIEU, batchId) ); setDialog({ open: true, data: data.data || [] }); }; const handleDownloadPdf = async (batchId?: string) => { try { if (!batchId) { alert("Missing batchId"); return; } setIsLoading(true); const isVoting = batchType === "BIEUQUYET"; const blobData = await queryClient.fetchQuery( isVoting ? downloadVotingPdf(batchId) : downloadElectionPdf(batchId) ); // The API returns Uint8Array for non-JSON responses (PDFs, etc.) const blob = new Blob([blobData], { type: "application/pdf", }); const blobUrl = URL.createObjectURL(blob); // Create a temporary anchor element to trigger download const link = document.createElement("a"); link.href = blobUrl; const linkName = language === "vi" ? isVoting ? "Giấy biểu quyết" : "Giấy bầu cử" : isVoting ? "Voting form" : "Election form"; link.download = `${linkName} ${batchName}.pdf`; document.body.appendChild(link); link.click(); // Cleanup document.body.removeChild(link); URL.revokeObjectURL(blobUrl); } catch (error) { showError( createErrorWithFallback( error as { errorInfo?: { errorCode?: string; errorMessageVI?: string; errorMessageEN?: string; }; statusCode?: number; }, { showErrorCode: true, } ) ); } finally { setIsLoading(false); } }; return ( <div className="flex flex-col xs:flex-row gap-x-6 gap-y-3 px-4 pb-3"> <div className="flex gap-3 items-center"> <Button type="button" variant="secondary" className="max-h-8 xs:max-h-[unset]" disabled={isLoading} onClick={() => handleDownloadPdf(batchId)} > {!isLoading ? <DownloadIcon /> : <Loader className="animate-spin" />} {t("voteElection.downloadBallot")} </Button> <Button type="button" variant="secondary" className="max-h-8 xs:max-h-[unset]" onClick={handleFetchGuide} > <DocumentIcon className="[&>path]:stroke-primary" /> {t("voteElection.manual")} </Button> </div> {onSubmit && ( <Button type="submit" onClick={onSubmit} className="xs:flex-1" disabled={isDisabled || isSubmitting} > {isSubmitting ? ( <div className="flex items-center justify-center gap-2"> <Loader className="animate-spin flex-1" /> <span>{t("voteElection.confirm") + "..."}</span> </div> ) : ( t("voteElection.confirm") )} </Button> )} <LazyMeetingDocumentDialog open={dialog.open} onOpenChange={open => { if (!open) setDialog({ ...dialog, open }); }} meetingInfos={dialog.data} /> </div> ); }; export default VotingElectionD132312323import CommonDialog, { type IconType } from "@/components/dialog"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { Form, FormControl, FormField, FormItem } from "@/components/ui/form"; import { Drawer, DrawerClose, DrawerContent, DrawerHeader, DrawerTitle, } from "@/components/ui/drawer"; import DocumentIcon from "@/icons/document-icon"; import DownloadIcon from "@/icons/download-icon"; import HourglassEagerIcon from "@/icons/hourglass-eager-icon"; import { cn } from "@/lib/utils"; import { useForm, useFormContext, useWatch, type UseFormReturn, } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import type { BaseSyntheticEvent } from "react"; import { lazy, Suspense, useEffect, useState } from "react"; import NumberOfShares from "./number-of-shares"; import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { useSet, useUpdateEffect } from "react-use"; import { ScrollArea } from "@/components/ui/scroll-area"; import { NumericFormat } from "react-number-format"; import type { BatchCandidatesResponse, BatchIssuesResponse, IssuesBatchResponse, IssuesVoteDTO, MeetingInfoDTO, } from "@/types/information.type"; import { BATCH_TYPES, INFO_TYPES, VOTE_OPTIONS, type BatchType, type VoteOption, type VoteStatus, } from "@/constants/params"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { candidatesByBatchIdQueryOptions, informationQueryOptions, issuesByBatchIdQueryOptions, } from "@/features/information/information.api"; import { getLocalizedProperties, isHttpStatusSuccess } from "@/lib/helper"; import { useTranslation } from "react-i18next"; import type { Language } from "@/constants/language"; import { format } from "date-fns"; import { TIME_FORMAT } from "@/constants/format"; import type { ApiResponse } from "@/types/api.type"; import { Skeleton } from "@/components/ui/skeleton"; import { LoadingOverlay } from "@/components/ui/loading-overlay"; import { useMediaQuery } from "react-responsive"; import CancelIcon from "@/icons/cancel-icon"; import NoContent from "@/components/no-content"; import ErrorContent from "@/components/error-content"; import { downloadElectionPdf, downloadVotingPdf, voteCandidatesOptions, voteIssuesOptions, } from "@/features/meeting/meeting.api"; import { useError } from "@/contexts/error-provider"; import { createErrorWithFallback } from "@/lib/error-utils"; import type { RefetchProps } from "../$meetId/-components/batch-list"; import useAuthApi from "@/features/auth/auth.api"; import { Loader } from "lucide-react"; const LazyMeetingDocumentDialog = lazy( () => import("@/components/meeting-document-dialog") ); const LazyOtpVerificationDialog = lazy(() => import("@/components/otp-dialog")); interface IProps { open: boolean; onOpenChange: (open: boolean) => void; batch: IssuesBatchResponse; onShowResultDialog: (dialog: { open: boolean; content: string }) => void; refetchBatches: RefetchProps; } type DialogType = { open: boolean; isSuccess: boolean; message: React.ReactNode; type: IconType; }; export function getBadgeColorByStatus(status: VoteStatus) { switch (status) { case "CHOBOPHIEU": return "bg-primary-03 text-white"; case "DABOPHIEU": return "bg-primary text-white"; default: return "bg-background-07 text-foreground-09 [&_svg_path]:!fill-[#c5c5c5] [&_svg_path]:!stroke-[#c5c5c5]"; } } // Function to create election schema with dynamic total limit const createElectionSchema = (totalLimit: number) => z .object({ candidates: z.record( z.string(), z.number().min(0, "voteElection.election.error.minVote") ), }) .refine( data => { const total = Object.values(data.candidates).reduce( (sum, votes) => sum + votes, 0 ); return total <= totalLimit; }, { message: "voteElection.election.error.maxVote", path: ["candidates"], } ); type ElectionFormValues = z.infer<ReturnType<typeof createElectionSchema>>; // Form schema for voting const votingSchema = z.object({ batchVote: z .enum([ VOTE_OPTIONS.AGREE, VOTE_OPTIONS.DISAGREE, VOTE_OPTIONS.NOIDEA, VOTE_OPTIONS.NONE, VOTE_OPTIONS.INVALID, ]) .optional(), individualVotes: z .record( z.string(), z.enum([ VOTE_OPTIONS.AGREE, VOTE_OPTIONS.DISAGREE, VOTE_OPTIONS.NOIDEA, VOTE_OPTIONS.NONE, VOTE_OPTIONS.INVALID, ]) ) .refine( record => { const selectedValues = Object.values(record); // Return expected result return selectedValues.every(value => value !== VOTE_OPTIONS.NONE); }, { error: "voteElection.vote.error" } ), }); type VotingFormValues = z.infer<typeof votingSchema>; const VotingElectionDialog = ({ open, onOpenChange, batch, onShowResultDialog, refetchBatches, }: IProps) => { const isElection = batch.batchType !== BATCH_TYPES.BIEUQUYET; const isMobile = useMediaQuery({ maxWidth: 768 }); // Use separate queries for different batch types - only run when dialog is actually open const issuesOptions = issuesByBatchIdQueryOptions(batch.batchId); const issuesQuery = useQuery({ ...issuesOptions, enabled: !isElection && open, queryKey: [...issuesOptions.queryKey, String(open)], // Refetch when dialog opens refetchOnMount: false, }); const candidatesOptions = candidatesByBatchIdQueryOptions(batch.batchId); const candidatesQuery = useQuery({ ...candidatesOptions, enabled: isElection && open, queryKey: [...candidatesOptions.queryKey, String(open)], // Refetch when dialog opens refetchOnMount: false, }); const { i18n } = useTranslation(); const language = i18n.language as Language; const data = isElection ? candidatesQuery.data : issuesQuery.data; const isLoading = isElection ? candidatesQuery.isLoading : issuesQuery.isLoading; const { batchName } = getLocalizedProperties(batch, ["batchName"], language); const contentComponent = !isElection ? ( <VotingContent onOpenChange={onOpenChange} isLoading={isLoading} data={data as ApiResponse<BatchIssuesResponse | null>} onShowResultDialog={onShowResultDialog} invalidateBatchesQuery={refetchBatches} /> ) : ( <ElectionContent onOpenChange={onOpenChange} data={data as ApiResponse<BatchCandidatesResponse | null>} isLoading={isLoading} onShowResultDialog={onShowResultDialog} invalidateBatchesQuery={refetchBatches} /> ); const headerComponent = <VotingElectionHeader batch={batch} />; if (isMobile) { return ( <Drawer open={open} onOpenChange={onOpenChange}> <DrawerContent className="max-h-[90vh]!"> <DrawerHeader className="border-b-2 border-background-02 gap-2"> <div className="flex items-center justify-between"> <DrawerTitle className="text-left text-foreground-12 font-bold responsive-text-3xl"> {batchName} </DrawerTitle> <DrawerClose asChild> <Button variant="ghost" className="size-6 p-0!"> <CancelIcon className="size-6" /> <span className="sr-only">Close</span> </Button> </DrawerClose> </div> {headerComponent} </DrawerHeader> <div className="flex-1 overflow-auto">{contentComponent}</div> </DrawerContent> </Drawer> ); } return ( <CommonDialog noTrans title={batchName} content={contentComponent} open={open} className={cn( "max-w-[360px] xs:max-w-[600px] sm:max-w-[800px] gap-0", "p-0! [&>[data-slot='dialog-header']]:p-4 [&>[data-slot='dialog-header']]:border-b-2 [&>[data-slot='dialog-header']]:border-background-02 [&>[data-slot='dialog-close']]:top-4 [&>[data-slot='dialog-close']]:right-4" )} showCloseBtn={false} onOpenChange={onOpenChange} header={headerComponent} /> ); }; const VotingElectionHeader = ({ batch }: { batch: IssuesBatchResponse }) => { const { t } = useTranslation(["overview"]); return ( <div className="flex items-stretch justify-between"> <Badge variant="outlineDestructive" className="h-[unset]"> <HourglassEagerIcon className="!size-4" /> {t("voteElection.endTime", { endTime: batch.endTime && format(batch.endTime, TIME_FORMAT), })} </Badge> <Button isSmall variant="secondary" className={cn( "max-h-8 p-1.5 responsive-text-sm", getBadgeColorByStatus(batch.votingStatus) )} > {batch.votingStatus == "CHOBOPHIEU" ? t("voteElection.status.waiting") : batch.votingStatus == "DABOPHIEU" ? t("voteElection.status.voted") : t("voteElection.status.closed")} </Button> </div> ); }; const VotingContent = ({ onOpenChange, isLoading, data, onShowResultDialog, invalidateBatchesQuery, }: { onOpenChange: (open: boolean) => void; isLoading: boolean; data: ApiResponse<BatchIssuesResponse | null>; onShowResultDialog: (open: { open: boolean; content: string }) => void; invalidateBatchesQuery: RefetchProps; }) => { const { sendOtpVerify } = useAuthApi(); const { showError } = useError(); const { t, i18n } = useTranslation(["overview"]); const [showOtpDialog, setShowOtpDialog] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false); const language = i18n.language as Language; const form = useForm<VotingFormValues>({ resolver: zodResolver(votingSchema), defaultValues: { batchVote: VOTE_OPTIONS.NONE, individualVotes: {}, }, }); const isVerifyOtp = data?.data?.isRequestOtp; const isDisabled = !data?.data?.isAllowedToVote; const handleOpenDialog = () => { setShowOtpDialog(true); setIsDialogOpen(true); }; const handleCloseDialog = (open: boolean) => { setIsDialogOpen(open); }; // Side effect to initialize form values when data changes useEffect(() => { if (data?.data?.votingIssues) { const initialVotes = data.data.votingIssues.reduce< Record<string, VoteOption> >((acc, cur) => { acc[cur.issueId.toString()] = cur.eVote; return acc; }, {}); // Determine initial batch vote const votes = Object.values(initialVotes); const initialBatchVote = votes.length > 0 && votes.every(vote => vote === votes[0] && vote !== VOTE_OPTIONS.NONE) ? votes[0] : VOTE_OPTIONS.NONE; // Update form values form.setValue("individualVotes", initialVotes); form.setValue("batchVote", initialBatchVote); } }, [data?.data?.votingIssues, form]); const addVoteIssuesMutation = useMutation(voteIssuesOptions()); const [dialog, setDialog] = useState<DialogType>({ open: false, isSuccess: true, message: "", type: "none", }); const handleSubmit = async (params: VotingFormValues) => { // Handle voting form submission console.log("Voting data:", params); if (isVerifyOtp) { sendOtpVerify.mutate(undefined, { onSuccess: function ({ statusCode, ticketId, errorInfo }) { if (isHttpStatusSuccess(statusCode)) { handleOpenDialog(); } else { showError( createErrorWithFallback( { errorInfo, statusCode, }, { details: ticketId || undefined, showErrorCode: true } ) ); } }, }); return; } mutationForm(); }; const onSubmit = form.handleSubmit(handleSubmit); const mutationForm = async (otp?: string) => { if (!data.data) return; const { errorInfo, statusCode, ticketId } = await addVoteIssuesMutation.mutateAsync({ batchId: data.data.batchId, shareQtyConfirm: data.data.shareQtyConfirm, issues: Object.entries(form.getValues().individualVotes).map( ([issueId, vote]) => ({ issueId: Number(issueId), eVote: vote, }) ), otp, }); if (!errorInfo && isHttpStatusSuccess(statusCode)) { await invalidateBatchesQuery(); onShowResultDialog({ open: true, content: t("voteElection.vote.success"), }); handleCloseDialog(false); onOpenChange(false); } else { showError( createErrorWithFallback( { errorInfo, statusCode, }, { details: ticketId || undefined, showErrorCode: true } ) ); } }; // Watch for batchVote changes and update all individual votes const batchVote = useWatch({ control: form.control, name: "batchVote" }); useEffect(() => { // Only update individual votes when batchVote has a valid value (not empty string) if (batchVote !== VOTE_OPTIONS.NONE && batchVote !== undefined) { const newIndividualVotes = data.data?.votingIssues?.reduce( (acc, item) => { acc[item.issueId.toString()] = batchVote; return acc; }, {} as Record<string, VoteOption> ); if (newIndividualVotes) { form.setValue("individualVotes", newIndividualVotes); } } }, [batchVote, form]); // Handle form errors useEffect(() => { if (Object.keys(form.formState.errors).length > 0) { // Handle errors here const errors = Object.values(form.formState.errors); const message = errors.map((err, i) => { const code = err.message?.toString() || err.root?.message; return ( <p key={i}> {errors.length > 1 && "• "} {t(code)} </p> ); }); setDialog({ open: true, message, isSuccess: false, type: "error" }); } }, [form.formState.errors]); return ( <Form {...form}> <div className="px-4 py-3.5"> <div className="flex flex-col gap-3"> {/* Bỏ phiếu hàng loạt */} <div className="grid grid-cols-1 gap-y-2 xs:grid-cols-3 sm:grid-cols-2 px-2.5"> <h2 className="responsive-text text-primary font-semibold text-left"> {t("voteElection.vote.title")} </h2> <FormField disabled={isDisabled} control={form.control} name="batchVote" render={({ field }) => ( <FormItem className="xs:col-span-2 sm:col-span-1"> <FormControl> <RadioGroup value={field.value} onValueChange={field.onChange} className="grid grid-cols-3 gap-5 justify-items-start xs:justify-items-end" > <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.AGREE} id="batch-approve" /> <Label htmlFor="batch-approve" className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.agree")} </Label> </div> <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.DISAGREE} id="batch-disapprove" /> <Label htmlFor="batch-disapprove" className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.disagree")} </Label> </div> <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.NOIDEA} id="batch-abstain" /> <Label htmlFor="batch-abstain" className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.noOption")} </Label> </div> </RadioGroup> </FormControl> </FormItem> )} /> </div> {/* Bỏ phiếu từng biểu quyết */} {!isLoading ? ( data?.data ? ( <ScrollArea className="rounded-default"> {data.data.votingIssues ? ( data.data.votingIssues?.map((item, index) => ( <VotingItem key={item.issueId} item={item} isDisabled={isDisabled} length={data.data?.votingIssues?.length || 0} index={index} form={form} /> )) ) : ( <NoContent /> )} </ScrollArea> ) : ( <ErrorContent errorInfo={data?.errorInfo} className="m-4" /> ) ) : ( <div className="relative min-h-[150px]"> <Skeleton className="h-full rounded-default" /> <LoadingOverlay visible={isLoading} /> </div> )} </div> </div> <VotingElectionFooter onSubmit={onSubmit} isDisabled={isDisabled || isDialogOpen} batchId={data?.data?.batchId} batchName={ language === "vi" ? data?.data?.batchName : data?.data?.batchNameEn } batchType={data?.data?.batchType} /> <CommonDialog className="md:max-w-sm!" open={dialog.open} onOpenChange={(open: boolean) => { if (!open && !dialog.isSuccess) form.clearErrors(); if (!open && dialog.isSuccess && dialog.type !== "none") onOpenChange(false); setDialog({ ...dialog, open }); }} content={dialog.message} iconType={dialog.isSuccess ? "success" : "error"} /> {showOtpDialog && ( <Suspense fallback={<LoadingOverlay visible={true} />}> <LazyOtpVerificationDialog open={isDialogOpen} onOpenChange={handleCloseDialog} onConfirm={mutationForm} /> </Suspense> )} </Form> ); }; const ElectionContent = ({ onOpenChange, data, isLoading, onShowResultDialog, invalidateBatchesQuery, }: { onOpenChange: (open: boolean) => void; data: ApiResponse<BatchCandidatesResponse | null>; isLoading: boolean; onShowResultDialog: (open: { open: boolean; content: string }) => void; invalidateBatchesQuery: RefetchProps; }) => { const { sendOtpVerify } = useAuthApi(); const { showError } = useError(); const { t, i18n } = useTranslation(["overview"]); const [showOtpDialog, setShowOtpDialog] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false); const language = i18n.language as Language; const isVerifyOtp = data?.data?.isRequestOtp; // Safe access to data with loading check const totalLimit = (!isLoading && data?.data?.candidateShareholder?.totalVotes) || 0; const isDisabled = !data?.data?.isAllowedToVote; // Create dynamic schema with the total limit from API const dynamicElectionSchema = createElectionSchema(totalLimit); const form = useForm<ElectionFormValues>({ resolver: zodResolver(dynamicElectionSchema), defaultValues: { candidates: {}, }, }); const handleOpenDialog = () => { setShowOtpDialog(true); setIsDialogOpen(true); }; const handleCloseDialog = (open: boolean) => { setIsDialogOpen(open); }; // Side effect to initialize form values when data changes useEffect(() => { if (!isLoading && data?.data?.votingCandidates) { const initialValues: Record<string, number> = {}; data.data.votingCandidates.forEach(candidate => { initialValues[candidate.candidateId.toString()] = candidate.ballotCounter || 0; }); form.setValue("candidates", initialValues); } }, [data?.data?.votingCandidates, isLoading]); const addVoteCandidatesMutation = useMutation(voteCandidatesOptions()); const [dialog, setDialog] = useState<DialogType>({ open: false, isSuccess: true, message: "", type: "none", }); const isMobileScreen = useMediaQuery({ maxWidth: 480 }); // Watch candidates form values and calculate total voted dynamically using useWatch (form.watch doesn't work) const watchedCandidates = useWatch({ control: form.control, name: "candidates", }) as Record<string, number> | undefined; const totalVoted = Object.values(watchedCandidates || {}).reduce( (sum, v) => sum + (typeof v === "number" ? v : Number(v || 0)), 0 ); const handleSubmit = async (data: ElectionFormValues) => { // Handle election form submission console.log("Election data:", data); if (totalVoted <= 0) { setDialog({ message: t("voteElection.election.error.minVote"), open: true, type: "error", isSuccess: false, }); return; } if (totalVoted < totalLimit) { setDialog({ message: t("voteElection.election.error.remainVote", { remaining: totalLimit - totalVoted, }), open: true, type: "none", isSuccess: true, }); return; } handleVerifyOtpBeforeSubmit(); }; const handleVerifyOtpBeforeSubmit = () => { if (isVerifyOtp) { sendOtpVerify.mutate(undefined, { onSuccess: function ({ statusCode, ticketId, errorInfo }) { if (isHttpStatusSuccess(statusCode)) { handleOpenDialog(); } else { showError( createErrorWithFallback( { errorInfo, statusCode, }, { details: ticketId || undefined, showErrorCode: true } ) ); } }, }); return; } mutationForm(); }; const onSubmit = form.handleSubmit(handleSubmit); const mutationForm = async (otp?: string) => { if (!data.data) return; const formData = form.getValues(); const { errorInfo, statusCode, ticketId } = await addVoteCandidatesMutation.mutateAsync({ batchId: data.data.batchId, batchType: data.data.batchType, candidates: Object.entries(formData.candidates).map( ([candidateId, ballotCounter]) => ({ candidateId: Number(candidateId), ballotCounter, }) ), otp, }); if (!errorInfo && isHttpStatusSuccess(statusCode)) { await invalidateBatchesQuery(); onShowResultDialog({ open: true, content: t("voteElection.election.success"), }); handleCloseDialog(false); onOpenChange(false); } else { showError( createErrorWithFallback( { errorInfo, statusCode, }, { details: ticketId || undefined, showErrorCode: true } ) ); } }; const candidates = (!isLoading && data?.data?.votingCandidates) || []; const [setCandidate, { add, has, remove, reset }] = useSet(new Set<number>()); // Initialize checkboxes for candidates with existing votes (ballotCounter > 0) useUpdateEffect(() => { if (!isLoading && candidates.length > 0) { // Clear existing selections first reset(); // Calculate candidates with votes > 0 const candidatesWithVotes = candidates.filter( candidate => candidate.ballotCounter > 0 ); if (candidatesWithVotes.length > 0) { // Calculate average votes per candidate with votes const avgVotesPerCandidate = Math.floor( totalLimit / candidatesWithVotes.length ); // Only check candidates whose ballotCounter equals the average candidates.forEach(candidate => { if (candidate.ballotCounter === avgVotesPerCandidate) { add(candidate.candidateId); } }); } // Initialize form values with existing ballotCounter values (preserve actual data) const formValues: Record<string, number> = {}; candidates.forEach(candidate => { formValues[candidate.candidateId.toString()] = candidate.ballotCounter || 0; }); form.setValue("candidates", formValues); } }, [candidates, isLoading, totalLimit]); // Handle manual checkbox changes by user interaction const handleCheckboxChange = (candidateId: number, checked: boolean) => { // compute previous size before mutating the set to avoid off-by-one const prevSize = setCandidate.size; if (checked) { add(candidateId); } else { remove(candidateId); } // Calculate new selection set size after the change const newSelectionSize = checked ? prevSize + 1 : Math.max(0, prevSize - 1); if (newSelectionSize > 0) { const avgVotes = Math.floor(totalLimit / newSelectionSize); const formValues: Record<string, number> = {}; candidates.forEach(candidate => { const willBeSelected = candidate.candidateId === candidateId ? checked : has(candidate.candidateId); formValues[candidate.candidateId.toString()] = willBeSelected ? avgVotes : 0; }); form.setValue("candidates", formValues); } else { // Reset all values to 0 when no candidates are selected const formValues: Record<string, number> = {}; candidates.forEach(candidate => { formValues[candidate.candidateId.toString()] = 0; }); form.setValue("candidates", formValues); } }; return ( <Form {...form}> {!isLoading ? ( data?.data ? ( <div className="flex flex-col gap-3.5 px-4 pt-2.5 pb-3"> <div className="flex flex-col gap-2"> <h2 className="responsive-text text-primary font-semibold text-left"> {t("voteElection.election.numElectionInfo")} </h2> <div className="flex max-sm:overflow-x-auto max-sm:overflow-y-hidden gap-3 pb-1"> <div className="max-sm:flex-shrink-0 max-w-40 xs:max-w-52 sm:max-w-64 lg:max-w-none"> <NumberOfShares title={t("shares.shareQtyConfirm")} count={ data?.data.candidateShareholder?.shareQtyConfirm || 0 } /> </div> <div className="max-sm:flex-shrink-0 max-w-40 xs:max-w-52 sm:max-w-64 lg:max-w-none"> <NumberOfShares title={t("shares.candidateCounter")} count={ data?.data.candidateShareholder?.candidateCounter || 0 } /> </div> <div className="max-sm:flex-shrink-0 max-w-40 xs:max-w-52 sm:max-w-64 lg:max-w-none"> <NumberOfShares title={t("shares.totalVotes")} count={data?.data.candidateShareholder?.totalVotes || 0} /> </div> </div> </div> <div className="flex flex-col gap-2 mb-4 rounded-lg"> <h2 className="responsive-text text-primary font-semibold text-left"> {t("voteElection.election.infoCandidate")} </h2> <Table className="text-left"> <TableHeader> <TableRow> <TableHead> {t("voteElection.election.table.candidateName", { tag: isMobileScreen ? <br key="br-1" /> : "", })} </TableHead> <TableHead className="text-center cursor-pointer" onClick={() => { if (setCandidate.size) { // Clear selection and reset form values reset(); form.reset(); } else { // Select all and distribute average votes candidates.forEach(i => add(i.candidateId)); const avgVotes = Math.floor( totalLimit / Math.max(1, candidates.length) ); const values: Record<string, number> = {}; candidates.forEach(c => { values[c.candidateId.toString()] = avgVotes; }); form.setValue("candidates", values); } }} > {t("voteElection.election.table.cumulativeVoting")}{" "} {isMobileScreen && <br key="br-2" />} </TableHead> <TableHead> {t("voteElection.election.table.numberVotes")} </TableHead> </TableRow> </TableHeader> <TableBody> {candidates.map(i => ( <TableRow key={i.candidateId}> <TableCell>{i.fullName}</TableCell> <TableCell className="text-center"> <Checkbox disabled={isDisabled} onCheckedChange={checked => { handleCheckboxChange( i.candidateId, checked === true ); }} checked={has(i.candidateId)} /> </TableCell> <TableCell> <FormField control={form.control} name={`candidates.${i.candidateId}`} render={({ field }) => ( <FormItem> <FormControl> <NumericFormat customInput={Input} placeholder={t( "voteElection.election.table.placeholder" )} className="px-3! py-1.5! min-w-28" disabled={!!setCandidate.size || isDisabled} value={field.value || ""} onValueChange={values => { const { floatValue } = values; field.onChange(floatValue || 0); }} thousandSeparator="," allowNegative={false} decimalScale={0} isAllowed={values => { const { floatValue } = values; return ( floatValue === undefined || floatValue >= 0 ); }} /> </FormControl> </FormItem> )} /> </TableCell> </TableRow> ))} </TableBody> <TableFooter> <TableRow> <TableCell className="text-foreground-05" colSpan={2}> {t("voteElection.election.table.totalVotes")} </TableCell> <TableCell className="text-foreground-05"> {totalVoted} </TableCell> </TableRow> </TableFooter> </Table> </div> </div> ) : ( <ErrorContent errorInfo={data?.errorInfo} className="m-4" /> ) ) : ( <div className="relative min-h-[400px]"> <Skeleton className="h-full rounded-default" /> <LoadingOverlay visible={isLoading} /> </div> )} <VotingElectionFooter onSubmit={onSubmit} isDisabled={isDisabled || isDialogOpen} batchId={data?.data?.batchId} batchName={ language === "vi" ? data?.data?.batchName : data?.data?.batchNameEn } batchType={data?.data?.batchType} /> {showOtpDialog && ( <Suspense fallback={<LoadingOverlay visible={true} />}> <LazyOtpVerificationDialog open={isDialogOpen} onOpenChange={handleCloseDialog} onConfirm={mutationForm} /> </Suspense> )} <CommonDialog open={dialog.open} onOpenChange={(open: boolean) => { if (!open && !dialog.isSuccess) form.clearErrors(); if (!open && dialog.isSuccess && dialog.type !== "none") onOpenChange(false); setDialog({ ...dialog, open }); }} rightButton={ dialog.isSuccess && dialog.type === "none" ? ( <Button className="flex-1" onClick={handleVerifyOtpBeforeSubmit}> {t("voteElection.confirm")} </Button> ) : null } content={dialog.message} iconType={dialog.type} className="md:max-w-sm!" /> </Form> ); }; const VotingItem = ({ item, index, form, length, isDisabled, }: { item: IssuesVoteDTO; index: number; form: UseFormReturn<VotingFormValues>; length: number; isDisabled: boolean; }) => { const { i18n, t } = useTranslation(["overview"]); const language = i18n.language as Language; const { content, description } = getLocalizedProperties( item, ["content", "description"], language ); return ( <div className={cn( "grid grid-cols-1 sm:grid-cols-2 gap-y-2 p-2.5 rounded-none bg-background-06", index < length - 1 && "border-b-2 border-background" )} > <div className="text-foreground-05 text-left flex flex-col gap-1.5"> <h3 className="responsive-text font-semibold">{content}</h3> <p className="responsive-text-sm">{description}</p> </div> <FormField disabled={isDisabled} control={form.control} name={`individualVotes.${item.issueId}`} render={({ field }) => ( <FormItem> <FormControl> <RadioGroup value={field.value} onValueChange={value => { // Clear batch vote when individual vote is changed form.setValue("batchVote", VOTE_OPTIONS.NONE); field.onChange(value); }} className="grid grid-cols-3 gap-5 justify-items-start sm:justify-items-end content-start" > <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.AGREE} id={`approve-${item.issueId}`} /> <Label htmlFor={`approve-${item.issueId}`} className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.agree")} </Label> </div> <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.DISAGREE} id={`disapprove-${item.issueId}`} /> <Label htmlFor={`disapprove-${item.issueId}`} className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.disagree")} </Label> </div> <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.NOIDEA} id={`abstain-${item.issueId}`} /> <Label htmlFor={`abstain-${item.issueId}`} className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.noOption")} </Label> </div> </RadioGroup> </FormControl> </FormItem> )} /> </div> ); }; const VotingElectionFooter = ({ onSubmit, isDisabled, batchId, batchName, batchType, }: { onSubmit?: ( e?: BaseSyntheticEvent<object, any, any> | undefined ) => Promise<void>; isDisabled: boolean; batchId?: string; batchName?: string; batchType?: BatchType; }) => { const form = useFormContext(); const [isLoading, setIsLoading] = useState(false); const { showError } = useError(); const isSubmitting = form.formState.isSubmitting; const { t } = useTranslation(["overview"]); const { i18n } = useTranslation(); const language = i18n.language as Language; const queryClient = useQueryClient(); const [dialog, setDialog] = useState<{ open: boolean; data: MeetingInfoDTO[]; }>({ data: [], open: false, }); const handleFetchGuide = async () => { if (!batchId) return alert("Missing batchId"); const data = await queryClient.fetchQuery( informationQueryOptions(INFO_TYPES.TAILIEU, batchId) ); setDialog({ open: true, data: data.data || [] }); }; const handleDownloadPdf = async (batchId?: string) => { try { if (!batchId) { alert("Missing batchId"); return; } setIsLoading(true); const isVoting = batchType === "BIEUQUYET"; const blobData = await queryClient.fetchQuery( isVoting ? downloadVotingPdf(batchId) : downloadElectionPdf(batchId) ); // The API returns Uint8Array for non-JSON responses (PDFs, etc.) const blob = new Blob([blobData], { type: "application/pdf", }); const blobUrl = URL.createObjectURL(blob); // Create a temporary anchor element to trigger download const link = document.createElement("a"); link.href = blobUrl; const linkName = language === "vi" ? isVoting ? "Giấy biểu quyết" : "Giấy bầu cử" : isVoting ? "Voting form" : "Election form"; link.download = `${linkName} ${batchName}.pdf`; document.body.appendChild(link); link.click(); // Cleanup document.body.removeChild(link); URL.revokeObjectURL(blobUrl); } catch (error) { showError( createErrorWithFallback( error as { errorInfo?: { errorCode?: string; errorMessageVI?: string; errorMessageEN?: string; }; statusCode?: number; }, { showErrorCode: true, } ) ); } finally { setIsLoading(false); } }; return ( <div className="flex flex-col xs:flex-row gap-x-6 gap-y-3 px-4 pb-3"> <div className="flex gap-3 items-center"> <Button type="button" variant="secondary" className="max-h-8 xs:max-h-[unset]" disabled={isLoading} onClick={() => handleDownloadPdf(batchId)} > {!isLoading ? <DownloadIcon /> : <Loader className="animate-spin" />} {t("voteElection.downloadBallot")} </Button> <Button type="button" variant="secondary" className="max-h-8 xs:max-h-[unset]" onClick={handleFetchGuide} > <DocumentIcon className="[&>path]:stroke-primary" /> {t("voteElection.manual")} </Button> </div> {onSubmit && ( <Button type="submit" onClick={onSubmit} className="xs:flex-1" disabled={isDisabled || isSubmitting} > {isSubmitting ? ( <div className="flex items-center justify-center gap-2"> <Loader className="animate-spin flex-1" /> <span>{t("voteElection.confirm") + "..."}</span> </div> ) : ( t("voteElection.confirm") )} </Button> )} <LazyMeetingDocumentDialog open={dialog.open} onOpenChange={open => { if (!open) setDialog({ ...dialog, open }); }} meetingInfos={dialog.data} /> </div> ); }; export default VotingElectionDialog;3211333333123import CommonDialog, { type IconType } from "@/components/dialog"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { Form, FormControl, FormField, FormItem } from "@/components/ui/form"; import { Drawer, DrawerClose, DrawerContent, DrawerHeader, DrawerTitle, } from "@/components/ui/drawer"; import DocumentIcon from "@/icons/document-icon"; import DownloadIcon from "@/icons/download-icon"; import HourglassEagerIcon from "@/icons/hourglass-eager-icon"; import { cn } from "@/lib/utils"; import { useForm, useFormContext, useWatch, type UseFormReturn, } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import type { BaseSyntheticEvent } from "react"; import { lazy, Suspense, useEffect, useState } from "react"; import NumberOfShares from "./number-of-shares"; import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { useSet, useUpdateEffect } from "react-use"; import { ScrollArea } from "@/components/ui/scroll-area"; import { NumericFormat } from "react-number-format"; import type { BatchCandidatesResponse, BatchIssuesResponse, IssuesBatchResponse, IssuesVoteDTO, MeetingInfoDTO, } from "@/types/information.type"; import { BATCH_TYPES, INFO_TYPES, VOTE_OPTIONS, type BatchType, type VoteOption, type VoteStatus, } from "@/constants/params"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { candidatesByBatchIdQueryOptions, informationQueryOptions, issuesByBatchIdQueryOptions, } from "@/features/information/information.api"; import { getLocalizedProperties, isHttpStatusSuccess } from "@/lib/helper"; import { useTranslation } from "react-i18next"; import type { Language } from "@/constants/language"; import { format } from "date-fns"; import { TIME_FORMAT } from "@/constants/format"; import type { ApiResponse } from "@/types/api.type"; import { Skeleton } from "@/components/ui/skeleton"; import { LoadingOverlay } from "@/components/ui/loading-overlay"; import { useMediaQuery } from "react-responsive"; import CancelIcon from "@/icons/cancel-icon"; import NoContent from "@/components/no-content"; import ErrorContent from "@/components/error-content"; import { downloadElectionPdf, downloadVotingPdf, voteCandidatesOptions, voteIssuesOptions, } from "@/features/meeting/meeting.api"; import { useError } from "@/contexts/error-provider"; import { createErrorWithFallback } from "@/lib/error-utils"; import type { RefetchProps } from "../$meetId/-components/batch-list"; import useAuthApi from "@/features/auth/auth.api"; import { Loader } from "lucide-react"; const LazyMeetingDocumentDialog = lazy( () => import("@/components/meeting-document-dialog") ); const LazyOtpVerificationDialog = lazy(() => import("@/components/otp-dialog")); interface IProps { open: boolean; onOpenChange: (open: boolean) => void; batch: IssuesBatchResponse; onShowResultDialog: (dialog: { open: boolean; content: string }) => void; refetchBatches: RefetchProps; } type DialogType = { open: boolean; isSuccess: boolean; message: React.ReactNode; type: IconType; }; export function getBadgeColorByStatus(status: VoteStatus) { switch (status) { case "CHOBOPHIEU": return "bg-primary-03 text-white"; case "DABOPHIEU": return "bg-primary text-white"; default: return "bg-background-07 text-foreground-09 [&_svg_path]:!fill-[#c5c5c5] [&_svg_path]:!stroke-[#c5c5c5]"; } } // Function to create election schema with dynamic total limit const createElectionSchema = (totalLimit: number) => z .object({ candidates: z.record( z.string(), z.number().min(0, "voteElection.election.error.minVote") ), }) .refine( data => { const total = Object.values(data.candidates).reduce( (sum, votes) => sum + votes, 0 ); return total <= totalLimit; }, { message: "voteElection.election.error.maxVote", path: ["candidates"], } ); type ElectionFormValues = z.infer<ReturnType<typeof createElectionSchema>>; // Form schema for voting const votingSchema = z.object({ batchVote: z .enum([ VOTE_OPTIONS.AGREE, VOTE_OPTIONS.DISAGREE, VOTE_OPTIONS.NOIDEA, VOTE_OPTIONS.NONE, VOTE_OPTIONS.INVALID, ]) .optional(), individualVotes: z .record( z.string(), z.enum([ VOTE_OPTIONS.AGREE, VOTE_OPTIONS.DISAGREE, VOTE_OPTIONS.NOIDEA, VOTE_OPTIONS.NONE, VOTE_OPTIONS.INVALID, ]) ) .refine( record => { const selectedValues = Object.values(record); // Return expected result return selectedValues.every(value => value !== VOTE_OPTIONS.NONE); }, { error: "voteElection.vote.error" } ), }); type VotingFormValues = z.infer<typeof votingSchema>; const VotingElectionDialog = ({ open, onOpenChange, batch, onShowResultDialog, refetchBatches, }: IProps) => { const isElection = batch.batchType !== BATCH_TYPES.BIEUQUYET; const isMobile = useMediaQuery({ maxWidth: 768 }); // Use separate queries for different batch types - only run when dialog is actually open const issuesOptions = issuesByBatchIdQueryOptions(batch.batchId); const issuesQuery = useQuery({ ...issuesOptions, enabled: !isElection && open, queryKey: [...issuesOptions.queryKey, String(open)], // Refetch when dialog opens refetchOnMount: false, }); const candidatesOptions = candidatesByBatchIdQueryOptions(batch.batchId); const candidatesQuery = useQuery({ ...candidatesOptions, enabled: isElection && open, queryKey: [...candidatesOptions.queryKey, String(open)], // Refetch when dialog opens refetchOnMount: false, }); const { i18n } = useTranslation(); const language = i18n.language as Language; const data = isElection ? candidatesQuery.data : issuesQuery.data; const isLoading = isElection ? candidatesQuery.isLoading : issuesQuery.isLoading; const { batchName } = getLocalizedProperties(batch, ["batchName"], language); const contentComponent = !isElection ? ( <VotingContent onOpenChange={onOpenChange} isLoading={isLoading} data={data as ApiResponse<BatchIssuesResponse | null>} onShowResultDialog={onShowResultDialog} invalidateBatchesQuery={refetchBatches} /> ) : ( <ElectionContent onOpenChange={onOpenChange} data={data as ApiResponse<BatchCandidatesResponse | null>} isLoading={isLoading} onShowResultDialog={onShowResultDialog} invalidateBatchesQuery={refetchBatches} /> ); const headerComponent = <VotingElectionHeader batch={batch} />; if (isMobile) { return ( <Drawer open={open} onOpenChange={onOpenChange}> <DrawerContent className="max-h-[90vh]!"> <DrawerHeader className="border-b-2 border-background-02 gap-2"> <div className="flex items-center justify-between"> <DrawerTitle className="text-left text-foreground-12 font-bold responsive-text-3xl"> {batchName} </DrawerTitle> <DrawerClose asChild> <Button variant="ghost" className="size-6 p-0!"> <CancelIcon className="size-6" /> <span className="sr-only">Close</span> </Button> </DrawerClose> </div> {headerComponent} </DrawerHeader> <div className="flex-1 overflow-auto">{contentComponent}</div> </DrawerContent> </Drawer> ); } return ( <CommonDialog noTrans title={batchName} content={contentComponent} open={open} className={cn( "max-w-[360px] xs:max-w-[600px] sm:max-w-[800px] gap-0", "p-0! [&>[data-slot='dialog-header']]:p-4 [&>[data-slot='dialog-header']]:border-b-2 [&>[data-slot='dialog-header']]:border-background-02 [&>[data-slot='dialog-close']]:top-4 [&>[data-slot='dialog-close']]:right-4" )} showCloseBtn={false} onOpenChange={onOpenChange} header={headerComponent} /> ); }; const VotingElectionHeader = ({ batch }: { batch: IssuesBatchResponse }) => { const { t } = useTranslation(["overview"]); return ( <div className="flex items-stretch justify-between"> <Badge variant="outlineDestructive" className="h-[unset]"> <HourglassEagerIcon className="!size-4" /> {t("voteElection.endTime", { endTime: batch.endTime && format(batch.endTime, TIME_FORMAT), })} </Badge> <Button isSmall variant="secondary" className={cn( "max-h-8 p-1.5 responsive-text-sm", getBadgeColorByStatus(batch.votingStatus) )} > {batch.votingStatus == "CHOBOPHIEU" ? t("voteElection.status.waiting") : batch.votingStatus == "DABOPHIEU" ? t("voteElection.status.voted") : t("voteElection.status.closed")} </Button> </div> ); }; const VotingContent = ({ onOpenChange, isLoading, data, onShowResultDialog, invalidateBatchesQuery, }: { onOpenChange: (open: boolean) => void; isLoading: boolean; data: ApiResponse<BatchIssuesResponse | null>; onShowResultDialog: (open: { open: boolean; content: string }) => void; invalidateBatchesQuery: RefetchProps; }) => { const { sendOtpVerify } = useAuthApi(); const { showError } = useError(); const { t, i18n } = useTranslation(["overview"]); const [showOtpDialog, setShowOtpDialog] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false); const language = i18n.language as Language; const form = useForm<VotingFormValues>({ resolver: zodResolver(votingSchema), defaultValues: { batchVote: VOTE_OPTIONS.NONE, individualVotes: {}, }, }); const isVerifyOtp = data?.data?.isRequestOtp; const isDisabled = !data?.data?.isAllowedToVote; const handleOpenDialog = () => { setShowOtpDialog(true); setIsDialogOpen(true); }; const handleCloseDialog = (open: boolean) => { setIsDialogOpen(open); }; // Side effect to initialize form values when data changes useEffect(() => { if (data?.data?.votingIssues) { const initialVotes = data.data.votingIssues.reduce< Record<string, VoteOption> >((acc, cur) => { acc[cur.issueId.toString()] = cur.eVote; return acc; }, {}); // Determine initial batch vote const votes = Object.values(initialVotes); const initialBatchVote = votes.length > 0 && votes.every(vote => vote === votes[0] && vote !== VOTE_OPTIONS.NONE) ? votes[0] : VOTE_OPTIONS.NONE; // Update form values form.setValue("individualVotes", initialVotes); form.setValue("batchVote", initialBatchVote); } }, [data?.data?.votingIssues, form]); const addVoteIssuesMutation = useMutation(voteIssuesOptions()); const [dialog, setDialog] = useState<DialogType>({ open: false, isSuccess: true, message: "", type: "none", }); const handleSubmit = async (params: VotingFormValues) => { // Handle voting form submission console.log("Voting data:", params); if (isVerifyOtp) { sendOtpVerify.mutate(undefined, { onSuccess: function ({ statusCode, ticketId, errorInfo }) { if (isHttpStatusSuccess(statusCode)) { handleOpenDialog(); } else { showError( createErrorWithFallback( { errorInfo, statusCode, }, { details: ticketId || undefined, showErrorCode: true } ) ); } }, }); return; } mutationForm(); }; const onSubmit = form.handleSubmit(handleSubmit); const mutationForm = async (otp?: string) => { if (!data.data) return; const { errorInfo, statusCode, ticketId } = await addVoteIssuesMutation.mutateAsync({ batchId: data.data.batchId, shareQtyConfirm: data.data.shareQtyConfirm, issues: Object.entries(form.getValues().individualVotes).map( ([issueId, vote]) => ({ issueId: Number(issueId), eVote: vote, }) ), otp, }); if (!errorInfo && isHttpStatusSuccess(statusCode)) { await invalidateBatchesQuery(); onShowResultDialog({ open: true, content: t("voteElection.vote.success"), }); handleCloseDialog(false); onOpenChange(false); } else { showError( createErrorWithFallback( { errorInfo, statusCode, }, { details: ticketId || undefined, showErrorCode: true } ) ); } }; // Watch for batchVote changes and update all individual votes const batchVote = useWatch({ control: form.control, name: "batchVote" }); useEffect(() => { // Only update individual votes when batchVote has a valid value (not empty string) if (batchVote !== VOTE_OPTIONS.NONE && batchVote !== undefined) { const newIndividualVotes = data.data?.votingIssues?.reduce( (acc, item) => { acc[item.issueId.toString()] = batchVote; return acc; }, {} as Record<string, VoteOption> ); if (newIndividualVotes) { form.setValue("individualVotes", newIndividualVotes); } } }, [batchVote, form]); // Handle form errors useEffect(() => { if (Object.keys(form.formState.errors).length > 0) { // Handle errors here const errors = Object.values(form.formState.errors); const message = errors.map((err, i) => { const code = err.message?.toString() || err.root?.message; return ( <p key={i}> {errors.length > 1 && "• "} {t(code)} </p> ); }); setDialog({ open: true, message, isSuccess: false, type: "error" }); } }, [form.formState.errors]); return ( <Form {...form}> <div className="px-4 py-3.5"> <div className="flex flex-col gap-3"> {/* Bỏ phiếu hàng loạt */} <div className="grid grid-cols-1 gap-y-2 xs:grid-cols-3 sm:grid-cols-2 px-2.5"> <h2 className="responsive-text text-primary font-semibold text-left"> {t("voteElection.vote.title")} </h2> <FormField disabled={isDisabled} control={form.control} name="batchVote" render={({ field }) => ( <FormItem className="xs:col-span-2 sm:col-span-1"> <FormControl> <RadioGroup value={field.value} onValueChange={field.onChange} className="grid grid-cols-3 gap-5 justify-items-start xs:justify-items-end" > <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.AGREE} id="batch-approve" /> <Label htmlFor="batch-approve" className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.agree")} </Label> </div> <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.DISAGREE} id="batch-disapprove" /> <Label htmlFor="batch-disapprove" className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.disagree")} </Label> </div> <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.NOIDEA} id="batch-abstain" /> <Label htmlFor="batch-abstain" className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.noOption")} </Label> </div> </RadioGroup> </FormControl> </FormItem> )} /> </div> {/* Bỏ phiếu từng biểu quyết */} {!isLoading ? ( data?.data ? ( <ScrollArea className="rounded-default"> {data.data.votingIssues ? ( data.data.votingIssues?.map((item, index) => ( <VotingItem key={item.issueId} item={item} isDisabled={isDisabled} length={data.data?.votingIssues?.length || 0} index={index} form={form} /> )) ) : ( <NoContent /> )} </ScrollArea> ) : ( <ErrorContent errorInfo={data?.errorInfo} className="m-4" /> ) ) : ( <div className="relative min-h-[150px]"> <Skeleton className="h-full rounded-default" /> <LoadingOverlay visible={isLoading} /> </div> )} </div> </div> <VotingElectionFooter onSubmit={onSubmit} isDisabled={isDisabled || isDialogOpen} batchId={data?.data?.batchId} batchName={ language === "vi" ? data?.data?.batchName : data?.data?.batchNameEn } batchType={data?.data?.batchType} /> <CommonDialog className="md:max-w-sm!" open={dialog.open} onOpenChange={(open: boolean) => { if (!open && !dialog.isSuccess) form.clearErrors(); if (!open && dialog.isSuccess && dialog.type !== "none") onOpenChange(false); setDialog({ ...dialog, open }); }} content={dialog.message} iconType={dialog.isSuccess ? "success" : "error"} /> {showOtpDialog && ( <Suspense fallback={<LoadingOverlay visible={true} />}> <LazyOtpVerificationDialog open={isDialogOpen} onOpenChange={handleCloseDialog} onConfirm={mutationForm} /> </Suspense> )} </Form> ); }; const ElectionContent = ({ onOpenChange, data, isLoading, onShowResultDialog, invalidateBatchesQuery, }: { onOpenChange: (open: boolean) => void; data: ApiResponse<BatchCandidatesResponse | null>; isLoading: boolean; onShowResultDialog: (open: { open: boolean; content: string }) => void; invalidateBatchesQuery: RefetchProps; }) => { const { sendOtpVerify } = useAuthApi(); const { showError } = useError(); const { t, i18n } = useTranslation(["overview"]); const [showOtpDialog, setShowOtpDialog] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false); const language = i18n.language as Language; const isVerifyOtp = data?.data?.isRequestOtp; // Safe access to data with loading check const totalLimit = (!isLoading && data?.data?.candidateShareholder?.totalVotes) || 0; const isDisabled = !data?.data?.isAllowedToVote; // Create dynamic schema with the total limit from API const dynamicElectionSchema = createElectionSchema(totalLimit); const form = useForm<ElectionFormValues>({ resolver: zodResolver(dynamicElectionSchema), defaultValues: { candidates: {}, }, }); const handleOpenDialog = () => { setShowOtpDialog(true); setIsDialogOpen(true); }; const handleCloseDialog = (open: boolean) => { setIsDialogOpen(open); }; // Side effect to initialize form values when data changes useEffect(() => { if (!isLoading && data?.data?.votingCandidates) { const initialValues: Record<string, number> = {}; data.data.votingCandidates.forEach(candidate => { initialValues[candidate.candidateId.toString()] = candidate.ballotCounter || 0; }); form.setValue("candidates", initialValues); } }, [data?.data?.votingCandidates, isLoading]); const addVoteCandidatesMutation = useMutation(voteCandidatesOptions()); const [dialog, setDialog] = useState<DialogType>({ open: false, isSuccess: true, message: "", type: "none", }); const isMobileScreen = useMediaQuery({ maxWidth: 480 }); // Watch candidates form values and calculate total voted dynamically using useWatch (form.watch doesn't work) const watchedCandidates = useWatch({ control: form.control, name: "candidates", }) as Record<string, number> | undefined; const totalVoted = Object.values(watchedCandidates || {}).reduce( (sum, v) => sum + (typeof v === "number" ? v : Number(v || 0)), 0 ); const handleSubmit = async (data: ElectionFormValues) => { // Handle election form submission console.log("Election data:", data); if (totalVoted <= 0) { setDialog({ message: t("voteElection.election.error.minVote"), open: true, type: "error", isSuccess: false, }); return; } if (totalVoted < totalLimit) { setDialog({ message: t("voteElection.election.error.remainVote", { remaining: totalLimit - totalVoted, }), open: true, type: "none", isSuccess: true, }); return; } handleVerifyOtpBeforeSubmit(); }; const handleVerifyOtpBeforeSubmit = () => { if (isVerifyOtp) { sendOtpVerify.mutate(undefined, { onSuccess: function ({ statusCode, ticketId, errorInfo }) { if (isHttpStatusSuccess(statusCode)) { handleOpenDialog(); } else { showError( createErrorWithFallback( { errorInfo, statusCode, }, { details: ticketId || undefined, showErrorCode: true } ) ); } }, }); return; } mutationForm(); }; const onSubmit = form.handleSubmit(handleSubmit); const mutationForm = async (otp?: string) => { if (!data.data) return; const formData = form.getValues(); const { errorInfo, statusCode, ticketId } = await addVoteCandidatesMutation.mutateAsync({ batchId: data.data.batchId, batchType: data.data.batchType, candidates: Object.entries(formData.candidates).map( ([candidateId, ballotCounter]) => ({ candidateId: Number(candidateId), ballotCounter, }) ), otp, }); if (!errorInfo && isHttpStatusSuccess(statusCode)) { await invalidateBatchesQuery(); onShowResultDialog({ open: true, content: t("voteElection.election.success"), }); handleCloseDialog(false); onOpenChange(false); } else { showError( createErrorWithFallback( { errorInfo, statusCode, }, { details: ticketId || undefined, showErrorCode: true } ) ); } }; const candidates = (!isLoading && data?.data?.votingCandidates) || []; const [setCandidate, { add, has, remove, reset }] = useSet(new Set<number>()); // Initialize checkboxes for candidates with existing votes (ballotCounter > 0) useUpdateEffect(() => { if (!isLoading && candidates.length > 0) { // Clear existing selections first reset(); // Calculate candidates with votes > 0 const candidatesWithVotes = candidates.filter( candidate => candidate.ballotCounter > 0 ); if (candidatesWithVotes.length > 0) { // Calculate average votes per candidate with votes const avgVotesPerCandidate = Math.floor( totalLimit / candidatesWithVotes.length ); // Only check candidates whose ballotCounter equals the average candidates.forEach(candidate => { if (candidate.ballotCounter === avgVotesPerCandidate) { add(candidate.candidateId); } }); } // Initialize form values with existing ballotCounter values (preserve actual data) const formValues: Record<string, number> = {}; candidates.forEach(candidate => { formValues[candidate.candidateId.toString()] = candidate.ballotCounter || 0; }); form.setValue("candidates", formValues); } }, [candidates, isLoading, totalLimit]); // Handle manual checkbox changes by user interaction const handleCheckboxChange = (candidateId: number, checked: boolean) => { // compute previous size before mutating the set to avoid off-by-one const prevSize = setCandidate.size; if (checked) { add(candidateId); } else { remove(candidateId); } // Calculate new selection set size after the change const newSelectionSize = checked ? prevSize + 1 : Math.max(0, prevSize - 1); if (newSelectionSize > 0) { const avgVotes = Math.floor(totalLimit / newSelectionSize); const formValues: Record<string, number> = {}; candidates.forEach(candidate => { const willBeSelected = candidate.candidateId === candidateId ? checked : has(candidate.candidateId); formValues[candidate.candidateId.toString()] = willBeSelected ? avgVotes : 0; }); form.setValue("candidates", formValues); } else { // Reset all values to 0 when no candidates are selected const formValues: Record<string, number> = {}; candidates.forEach(candidate => { formValues[candidate.candidateId.toString()] = 0; }); form.setValue("candidates", formValues); } }; return ( <Form {...form}> {!isLoading ? ( data?.data ? ( <div className="flex flex-col gap-3.5 px-4 pt-2.5 pb-3"> <div className="flex flex-col gap-2"> <h2 className="responsive-text text-primary font-semibold text-left"> {t("voteElection.election.numElectionInfo")} </h2> <div className="flex max-sm:overflow-x-auto max-sm:overflow-y-hidden gap-3 pb-1"> <div className="max-sm:flex-shrink-0 max-w-40 xs:max-w-52 sm:max-w-64 lg:max-w-none"> <NumberOfShares title={t("shares.shareQtyConfirm")} count={ data?.data.candidateShareholder?.shareQtyConfirm || 0 } /> </div> <div className="max-sm:flex-shrink-0 max-w-40 xs:max-w-52 sm:max-w-64 lg:max-w-none"> <NumberOfShares title={t("shares.candidateCounter")} count={ data?.data.candidateShareholder?.candidateCounter || 0 } /> </div> <div className="max-sm:flex-shrink-0 max-w-40 xs:max-w-52 sm:max-w-64 lg:max-w-none"> <NumberOfShares title={t("shares.totalVotes")} count={data?.data.candidateShareholder?.totalVotes || 0} /> </div> </div> </div> <div className="flex flex-col gap-2 mb-4 rounded-lg"> <h2 className="responsive-text text-primary font-semibold text-left"> {t("voteElection.election.infoCandidate")} </h2> <Table className="text-left"> <TableHeader> <TableRow> <TableHead> {t("voteElection.election.table.candidateName", { tag: isMobileScreen ? <br key="br-1" /> : "", })} </TableHead> <TableHead className="text-center cursor-pointer" onClick={() => { if (setCandidate.size) { // Clear selection and reset form values reset(); form.reset(); } else { // Select all and distribute average votes candidates.forEach(i => add(i.candidateId)); const avgVotes = Math.floor( totalLimit / Math.max(1, candidates.length) ); const values: Record<string, number> = {}; candidates.forEach(c => { values[c.candidateId.toString()] = avgVotes; }); form.setValue("candidates", values); } }} > {t("voteElection.election.table.cumulativeVoting")}{" "} {isMobileScreen && <br key="br-2" />} </TableHead> <TableHead> {t("voteElection.election.table.numberVotes")} </TableHead> </TableRow> </TableHeader> <TableBody> {candidates.map(i => ( <TableRow key={i.candidateId}> <TableCell>{i.fullName}</TableCell> <TableCell className="text-center"> <Checkbox disabled={isDisabled} onCheckedChange={checked => { handleCheckboxChange( i.candidateId, checked === true ); }} checked={has(i.candidateId)} /> </TableCell> <TableCell> <FormField control={form.control} name={`candidates.${i.candidateId}`} render={({ field }) => ( <FormItem> <FormControl> <NumericFormat customInput={Input} placeholder={t( "voteElection.election.table.placeholder" )} className="px-3! py-1.5! min-w-28" disabled={!!setCandidate.size || isDisabled} value={field.value || ""} onValueChange={values => { const { floatValue } = values; field.onChange(floatValue || 0); }} thousandSeparator="," allowNegative={false} decimalScale={0} isAllowed={values => { const { floatValue } = values; return ( floatValue === undefined || floatValue >= 0 ); }} /> </FormControl> </FormItem> )} /> </TableCell> </TableRow> ))} </TableBody> <TableFooter> <TableRow> <TableCell className="text-foreground-05" colSpan={2}> {t("voteElection.election.table.totalVotes")} </TableCell> <TableCell className="text-foreground-05"> {totalVoted} </TableCell> </TableRow> </TableFooter> </Table> </div> </div> ) : ( <ErrorContent errorInfo={data?.errorInfo} className="m-4" /> ) ) : ( <div className="relative min-h-[400px]"> <Skeleton className="h-full rounded-default" /> <LoadingOverlay visible={isLoading} /> </div> )} <VotingElectionFooter onSubmit={onSubmit} isDisabled={isDisabled || isDialogOpen} batchId={data?.data?.batchId} batchName={ language === "vi" ? data?.data?.batchName : data?.data?.batchNameEn } batchType={data?.data?.batchType} /> {showOtpDialog && ( <Suspense fallback={<LoadingOverlay visible={true} />}> <LazyOtpVerificationDialog open={isDialogOpen} onOpenChange={handleCloseDialog} onConfirm={mutationForm} /> </Suspense> )} <CommonDialog open={dialog.open} onOpenChange={(open: boolean) => { if (!open && !dialog.isSuccess) form.clearErrors(); if (!open && dialog.isSuccess && dialog.type !== "none") onOpenChange(false); setDialog({ ...dialog, open }); }} rightButton={ dialog.isSuccess && dialog.type === "none" ? ( <Button className="flex-1" onClick={handleVerifyOtpBeforeSubmit}> {t("voteElection.confirm")} </Button> ) : null } content={dialog.message} iconType={dialog.type} className="md:max-w-sm!" /> </Form> ); }; const VotingItem = ({ item, index, form, length, isDisabled, }: { item: IssuesVoteDTO; index: number; form: UseFormReturn<VotingFormValues>; length: number; isDisabled: boolean; }) => { const { i18n, t } = useTranslation(["overview"]); const language = i18n.language as Language; const { content, description } = getLocalizedProperties( item, ["content", "description"], language ); return ( <div className={cn( "grid grid-cols-1 sm:grid-cols-2 gap-y-2 p-2.5 rounded-none bg-background-06", index < length - 1 && "border-b-2 border-background" )} > <div className="text-foreground-05 text-left flex flex-col gap-1.5"> <h3 className="responsive-text font-semibold">{content}</h3> <p className="responsive-text-sm">{description}</p> </div> <FormField disabled={isDisabled} control={form.control} name={`individualVotes.${item.issueId}`} render={({ field }) => ( <FormItem> <FormControl> <RadioGroup value={field.value} onValueChange={value => { // Clear batch vote when individual vote is changed form.setValue("batchVote", VOTE_OPTIONS.NONE); field.onChange(value); }} className="grid grid-cols-3 gap-5 justify-items-start sm:justify-items-end content-start" > <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.AGREE} id={`approve-${item.issueId}`} /> <Label htmlFor={`approve-${item.issueId}`} className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.agree")} </Label> </div> <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.DISAGREE} id={`disapprove-${item.issueId}`} /> <Label htmlFor={`disapprove-${item.issueId}`} className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.disagree")} </Label> </div> <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.NOIDEA} id={`abstain-${item.issueId}`} /> <Label htmlFor={`abstain-${item.issueId}`} className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.noOption")} </Label> </div> </RadioGroup> </FormControl> </FormItem> )} /> </div> ); }; const VotingElectionFooter = ({ onSubmit, isDisabled, batchId, batchName, batchType, }: { onSubmit?: ( e?: BaseSyntheticEvent<object, any, any> | undefined ) => Promise<void>; isDisabled: boolean; batchId?: string; batchName?: string; batchType?: BatchType; }) => { const form = useFormContext(); const [isLoading, setIsLoading] = useState(false); const { showError } = useError(); const isSubmitting = form.formState.isSubmitting; const { t } = useTranslation(["overview"]); const { i18n } = useTranslation(); const language = i18n.language as Language; const queryClient = useQueryClient(); const [dialog, setDialog] = useState<{ open: boolean; data: MeetingInfoDTO[]; }>({ data: [], open: false, }); const handleFetchGuide = async () => { if (!batchId) return alert("Missing batchId"); const data = await queryClient.fetchQuery( informationQueryOptions(INFO_TYPES.TAILIEU, batchId) ); setDialog({ open: true, data: data.data || [] }); }; const handleDownloadPdf = async (batchId?: string) => { try { if (!batchId) { alert("Missing batchId"); return; } setIsLoading(true); const isVoting = batchType === "BIEUQUYET"; const blobData = await queryClient.fetchQuery( isVoting ? downloadVotingPdf(batchId) : downloadElectionPdf(batchId) ); // The API returns Uint8Array for non-JSON responses (PDFs, etc.) const blob = new Blob([blobData], { type: "application/pdf", }); const blobUrl = URL.createObjectURL(blob); // Create a temporary anchor element to trigger download const link = document.createElement("a"); link.href = blobUrl; const linkName = language === "vi" ? isVoting ? "Giấy biểu quyết" : "Giấy bầu cử" : isVoting ? "Voting form" : "Election form"; link.download = `${linkName} ${batchName}.pdf`; document.body.appendChild(link); link.click(); // Cleanup document.body.removeChild(link); URL.revokeObjectURL(blobUrl); } catch (error) { showError( createErrorWithFallback( error as { errorInfo?: { errorCode?: string; errorMessageVI?: string; errorMessageEN?: string; }; statusCode?: number; }, { showErrorCode: true, } ) ); } finally { setIsLoading(false); } }; return ( <div className="flex flex-col xs:flex-row gap-x-6 gap-y-3 px-4 pb-3"> <div className="flex gap-3 items-center"> <Button type="button" variant="secondary" className="max-h-8 xs:max-h-[unset]" disabled={isLoading} onClick={() => handleDownloadPdf(batchId)} > {!isLoading ? <DownloadIcon /> : <Loader className="animate-spin" />} {t("voteElection.downloadBallot")} </Button> <Button type="button" variant="secondary" className="max-h-8 xs:max-h-[unset]" onClick={handleFetchGuide} > <DocumentIcon className="[&>path]:stroke-primary" /> {t("voteElection.manual")} </Button> </div> {onSubmit && ( <Button type="submit" onClick={onSubmit} className="xs:flex-1" disabled={isDisabled || isSubmitting} > {isSubmitting ? ( <div className="flex items-center justify-center gap-2"> <Loader className="animate-spin flex-1" /> <span>{t("voteElection.confirm") + "..."}</span> </div> ) : ( t("voteElection.confirm") )} </Button> )} <LazyMeetingDocumentDialog open={dialog.open} onOpenChange={open => { if (!open) setDialog({ ...dialog, open }); }} meetingInfos={dialog.data} /> </div> ); }; export default VotingElectionDialog;344243import CommonDialog, { type IconType } from "@/components/dialog"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { Form, FormControl, FormField, FormItem } from "@/components/ui/form"; import { Drawer, DrawerClose, DrawerContent, DrawerHeader, DrawerTitle, } from "@/components/ui/drawer"; import DocumentIcon from "@/icons/document-icon"; import DownloadIcon from "@/icons/download-icon"; import HourglassEagerIcon from "@/icons/hourglass-eager-icon"; import { cn } from "@/lib/utils"; import { useForm, useFormContext, useWatch, type UseFormReturn, } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import type { BaseSyntheticEvent } from "react"; import { lazy, Suspense, useEffect, useState } from "react"; import NumberOfShares from "./number-of-shares"; import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { useSet, useUpdateEffect } from "react-use"; import { ScrollArea } from "@/components/ui/scroll-area"; import { NumericFormat } from "react-number-format"; import type { BatchCandidatesResponse, BatchIssuesResponse, IssuesBatchResponse, IssuesVoteDTO, MeetingInfoDTO, } from "@/types/information.type"; import { BATCH_TYPES, INFO_TYPES, VOTE_OPTIONS, type BatchType, type VoteOption, type VoteStatus, } from "@/constants/params"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { candidatesByBatchIdQueryOptions, informationQueryOptions, issuesByBatchIdQueryOptions, } from "@/features/information/information.api"; import { getLocalizedProperties, isHttpStatusSuccess } from "@/lib/helper"; import { useTranslation } from "react-i18next"; import type { Language } from "@/constants/language"; import { format } from "date-fns"; import { TIME_FORMAT } from "@/constants/format"; import type { ApiResponse } from "@/types/api.type"; import { Skeleton } from "@/components/ui/skeleton"; import { LoadingOverlay } from "@/components/ui/loading-overlay"; import { useMediaQuery } from "react-responsive"; import CancelIcon from "@/icons/cancel-icon"; import NoContent from "@/components/no-content"; import ErrorContent from "@/components/error-content"; import { downloadElectionPdf, downloadVotingPdf, voteCandidatesOptions, voteIssuesOptions, } from "@/features/meeting/meeting.api"; import { useError } from "@/contexts/error-provider"; import { createErrorWithFallback } from "@/lib/error-utils"; import type { RefetchProps } from "../$meetId/-components/batch-list"; import useAuthApi from "@/features/auth/auth.api"; import { Loader } from "lucide-react"; const LazyMeetingDocumentDialog = lazy( () => import("@/components/meeting-document-dialog") ); const LazyOtpVerificationDialog = lazy(() => import("@/components/otp-dialog")); interface IProps { open: boolean; onOpenChange: (open: boolean) => void; batch: IssuesBatchResponse; onShowResultDialog: (dialog: { open: boolean; content: string }) => void; refetchBatches: RefetchProps; } type DialogType = { open: boolean; isSuccess: boolean; message: React.ReactNode; type: IconType; }; export function getBadgeColorByStatus(status: VoteStatus) { switch (status) { case "CHOBOPHIEU": return "bg-primary-03 text-white"; case "DABOPHIEU": return "bg-primary text-white"; default: return "bg-background-07 text-foreground-09 [&_svg_path]:!fill-[#c5c5c5] [&_svg_path]:!stroke-[#c5c5c5]"; } } // Function to create election schema with dynamic total limit const createElectionSchema = (totalLimit: number) => z .object({ candidates: z.record( z.string(), z.number().min(0, "voteElection.election.error.minVote") ), }) .refine( data => { const total = Object.values(data.candidates).reduce( (sum, votes) => sum + votes, 0 ); return total <= totalLimit; }, { message: "voteElection.election.error.maxVote", path: ["candidates"], } ); type ElectionFormValues = z.infer<ReturnType<typeof createElectionSchema>>; // Form schema for voting const votingSchema = z.object({ batchVote: z .enum([ VOTE_OPTIONS.AGREE, VOTE_OPTIONS.DISAGREE, VOTE_OPTIONS.NOIDEA, VOTE_OPTIONS.NONE, VOTE_OPTIONS.INVALID, ]) .optional(), individualVotes: z .record( z.string(), z.enum([ VOTE_OPTIONS.AGREE, VOTE_OPTIONS.DISAGREE, VOTE_OPTIONS.NOIDEA, VOTE_OPTIONS.NONE, VOTE_OPTIONS.INVALID, ]) ) .refine( record => { const selectedValues = Object.values(record); // Return expected result return selectedValues.every(value => value !== VOTE_OPTIONS.NONE); }, { error: "voteElection.vote.error" } ), }); type VotingFormValues = z.infer<typeof votingSchema>; const VotingElectionDialog = ({ open, onOpenChange, batch, onShowResultDialog, refetchBatches, }: IProps) => { const isElection = batch.batchType !== BATCH_TYPES.BIEUQUYET; const isMobile = useMediaQuery({ maxWidth: 768 }); // Use separate queries for different batch types - only run when dialog is actually open const issuesOptions = issuesByBatchIdQueryOptions(batch.batchId); const issuesQuery = useQuery({ ...issuesOptions, enabled: !isElection && open, queryKey: [...issuesOptions.queryKey, String(open)], // Refetch when dialog opens refetchOnMount: false, }); const candidatesOptions = candidatesByBatchIdQueryOptions(batch.batchId); const candidatesQuery = useQuery({ ...candidatesOptions, enabled: isElection && open, queryKey: [...candidatesOptions.queryKey, String(open)], // Refetch when dialog opens refetchOnMount: false, }); const { i18n } = useTranslation(); const language = i18n.language as Language; const data = isElection ? candidatesQuery.data : issuesQuery.data; const isLoading = isElection ? candidatesQuery.isLoading : issuesQuery.isLoading; const { batchName } = getLocalizedProperties(batch, ["batchName"], language); const contentComponent = !isElection ? ( <VotingContent onOpenChange={onOpenChange} isLoading={isLoading} data={data as ApiResponse<BatchIssuesResponse | null>} onShowResultDialog={onShowResultDialog} invalidateBatchesQuery={refetchBatches} /> ) : ( <ElectionContent onOpenChange={onOpenChange} data={data as ApiResponse<BatchCandidatesResponse | null>} isLoading={isLoading} onShowResultDialog={onShowResultDialog} invalidateBatchesQuery={refetchBatches} /> ); const headerComponent = <VotingElectionHeader batch={batch} />; if (isMobile) { return ( <Drawer open={open} onOpenChange={onOpenChange}> <DrawerContent className="max-h-[90vh]!"> <DrawerHeader className="border-b-2 border-background-02 gap-2"> <div className="flex items-center justify-between"> <DrawerTitle className="text-left text-foreground-12 font-bold responsive-text-3xl"> {batchName} </DrawerTitle> <DrawerClose asChild> <Button variant="ghost" className="size-6 p-0!"> <CancelIcon className="size-6" /> <span className="sr-only">Close</span> </Button> </DrawerClose> </div> {headerComponent} </DrawerHeader> <div className="flex-1 overflow-auto">{contentComponent}</div> </DrawerContent> </Drawer> ); } return ( <CommonDialog noTrans title={batchName} content={contentComponent} open={open} className={cn( "max-w-[360px] xs:max-w-[600px] sm:max-w-[800px] gap-0", "p-0! [&>[data-slot='dialog-header']]:p-4 [&>[data-slot='dialog-header']]:border-b-2 [&>[data-slot='dialog-header']]:border-background-02 [&>[data-slot='dialog-close']]:top-4 [&>[data-slot='dialog-close']]:right-4" )} showCloseBtn={false} onOpenChange={onOpenChange} header={headerComponent} /> ); }; const VotingElectionHeader = ({ batch }: { batch: IssuesBatchResponse }) => { const { t } = useTranslation(["overview"]); return ( <div className="flex items-stretch justify-between"> <Badge variant="outlineDestructive" className="h-[unset]"> <HourglassEagerIcon className="!size-4" /> {t("voteElection.endTime", { endTime: batch.endTime && format(batch.endTime, TIME_FORMAT), })} </Badge> <Button isSmall variant="secondary" className={cn( "max-h-8 p-1.5 responsive-text-sm", getBadgeColorByStatus(batch.votingStatus) )} > {batch.votingStatus == "CHOBOPHIEU" ? t("voteElection.status.waiting") : batch.votingStatus == "DABOPHIEU" ? t("voteElection.status.voted") : t("voteElection.status.closed")} </Button> </div> ); }; const VotingContent = ({ onOpenChange, isLoading, data, onShowResultDialog, invalidateBatchesQuery, }: { onOpenChange: (open: boolean) => void; isLoading: boolean; data: ApiResponse<BatchIssuesResponse | null>; onShowResultDialog: (open: { open: boolean; content: string }) => void; invalidateBatchesQuery: RefetchProps; }) => { const { sendOtpVerify } = useAuthApi(); const { showError } = useError(); const { t, i18n } = useTranslation(["overview"]); const [showOtpDialog, setShowOtpDialog] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false); const language = i18n.language as Language; const form = useForm<VotingFormValues>({ resolver: zodResolver(votingSchema), defaultValues: { batchVote: VOTE_OPTIONS.NONE, individualVotes: {}, }, }); const isVerifyOtp = data?.data?.isRequestOtp; const isDisabled = !data?.data?.isAllowedToVote; const handleOpenDialog = () => { setShowOtpDialog(true); setIsDialogOpen(true); }; const handleCloseDialog = (open: boolean) => { setIsDialogOpen(open); }; // Side effect to initialize form values when data changes useEffect(() => { if (data?.data?.votingIssues) { const initialVotes = data.data.votingIssues.reduce< Record<string, VoteOption> >((acc, cur) => { acc[cur.issueId.toString()] = cur.eVote; return acc; }, {}); // Determine initial batch vote const votes = Object.values(initialVotes); const initialBatchVote = votes.length > 0 && votes.every(vote => vote === votes[0] && vote !== VOTE_OPTIONS.NONE) ? votes[0] : VOTE_OPTIONS.NONE; // Update form values form.setValue("individualVotes", initialVotes); form.setValue("batchVote", initialBatchVote); } }, [data?.data?.votingIssues, form]); const addVoteIssuesMutation = useMutation(voteIssuesOptions()); const [dialog, setDialog] = useState<DialogType>({ open: false, isSuccess: true, message: "", type: "none", }); const handleSubmit = async (params: VotingFormValues) => { // Handle voting form submission console.log("Voting data:", params); if (isVerifyOtp) { sendOtpVerify.mutate(undefined, { onSuccess: function ({ statusCode, ticketId, errorInfo }) { if (isHttpStatusSuccess(statusCode)) { handleOpenDialog(); } else { showError( createErrorWithFallback( { errorInfo, statusCode, }, { details: ticketId || undefined, showErrorCode: true } ) ); } }, }); return; } mutationForm(); }; const onSubmit = form.handleSubmit(handleSubmit); const mutationForm = async (otp?: string) => { if (!data.data) return; const { errorInfo, statusCode, ticketId } = await addVoteIssuesMutation.mutateAsync({ batchId: data.data.batchId, shareQtyConfirm: data.data.shareQtyConfirm, issues: Object.entries(form.getValues().individualVotes).map( ([issueId, vote]) => ({ issueId: Number(issueId), eVote: vote, }) ), otp, }); if (!errorInfo && isHttpStatusSuccess(statusCode)) { await invalidateBatchesQuery(); onShowResultDialog({ open: true, content: t("voteElection.vote.success"), }); handleCloseDialog(false); onOpenChange(false); } else { showError( createErrorWithFallback( { errorInfo, statusCode, }, { details: ticketId || undefined, showErrorCode: true } ) ); } }; // Watch for batchVote changes and update all individual votes const batchVote = useWatch({ control: form.control, name: "batchVote" }); useEffect(() => { // Only update individual votes when batchVote has a valid value (not empty string) if (batchVote !== VOTE_OPTIONS.NONE && batchVote !== undefined) { const newIndividualVotes = data.data?.votingIssues?.reduce( (acc, item) => { acc[item.issueId.toString()] = batchVote; return acc; }, {} as Record<string, VoteOption> ); if (newIndividualVotes) { form.setValue("individualVotes", newIndividualVotes); } } }, [batchVote, form]); // Handle form errors useEffect(() => { if (Object.keys(form.formState.errors).length > 0) { // Handle errors here const errors = Object.values(form.formState.errors); const message = errors.map((err, i) => { const code = err.message?.toString() || err.root?.message; return ( <p key={i}> {errors.length > 1 && "• "} {t(code)} </p> ); }); setDialog({ open: true, message, isSuccess: false, type: "error" }); } }, [form.formState.errors]); return ( <Form {...form}> <div className="px-4 py-3.5"> <div className="flex flex-col gap-3"> {/* Bỏ phiếu hàng loạt */} <div className="grid grid-cols-1 gap-y-2 xs:grid-cols-3 sm:grid-cols-2 px-2.5"> <h2 className="responsive-text text-primary font-semibold text-left"> {t("voteElection.vote.title")} </h2> <FormField disabled={isDisabled} control={form.control} name="batchVote" render={({ field }) => ( <FormItem className="xs:col-span-2 sm:col-span-1"> <FormControl> <RadioGroup value={field.value} onValueChange={field.onChange} className="grid grid-cols-3 gap-5 justify-items-start xs:justify-items-end" > <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.AGREE} id="batch-approve" /> <Label htmlFor="batch-approve" className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.agree")} </Label> </div> <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.DISAGREE} id="batch-disapprove" /> <Label htmlFor="batch-disapprove" className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.disagree")} </Label> </div> <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.NOIDEA} id="batch-abstain" /> <Label htmlFor="batch-abstain" className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.noOption")} </Label> </div> </RadioGroup> </FormControl> </FormItem> )} /> </div> {/* Bỏ phiếu từng biểu quyết */} {!isLoading ? ( data?.data ? ( <ScrollArea className="rounded-default"> {data.data.votingIssues ? ( data.data.votingIssues?.map((item, index) => ( <VotingItem key={item.issueId} item={item} isDisabled={isDisabled} length={data.data?.votingIssues?.length || 0} index={index} form={form} /> )) ) : ( <NoContent /> )} </ScrollArea> ) : ( <ErrorContent errorInfo={data?.errorInfo} className="m-4" /> ) ) : ( <div className="relative min-h-[150px]"> <Skeleton className="h-full rounded-default" /> <LoadingOverlay visible={isLoading} /> </div> )} </div> </div> <VotingElectionFooter onSubmit={onSubmit} isDisabled={isDisabled || isDialogOpen} batchId={data?.data?.batchId} batchName={ language === "vi" ? data?.data?.batchName : data?.data?.batchNameEn } batchType={data?.data?.batchType} /> <CommonDialog className="md:max-w-sm!" open={dialog.open} onOpenChange={(open: boolean) => { if (!open && !dialog.isSuccess) form.clearErrors(); if (!open && dialog.isSuccess && dialog.type !== "none") onOpenChange(false); setDialog({ ...dialog, open }); }} content={dialog.message} iconType={dialog.isSuccess ? "success" : "error"} /> {showOtpDialog && ( <Suspense fallback={<LoadingOverlay visible={true} />}> <LazyOtpVerificationDialog open={isDialogOpen} onOpenChange={handleCloseDialog} onConfirm={mutationForm} /> </Suspense> )} </Form> ); }; const ElectionContent = ({ onOpenChange, data, isLoading, onShowResultDialog, invalidateBatchesQuery, }: { onOpenChange: (open: boolean) => void; data: ApiResponse<BatchCandidatesResponse | null>; isLoading: boolean; onShowResultDialog: (open: { open: boolean; content: string }) => void; invalidateBatchesQuery: RefetchProps; }) => { const { sendOtpVerify } = useAuthApi(); const { showError } = useError(); const { t, i18n } = useTranslation(["overview"]); const [showOtpDialog, setShowOtpDialog] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false); const language = i18n.language as Language; const isVerifyOtp = data?.data?.isRequestOtp; // Safe access to data with loading check const totalLimit = (!isLoading && data?.data?.candidateShareholder?.totalVotes) || 0; const isDisabled = !data?.data?.isAllowedToVote; // Create dynamic schema with the total limit from API const dynamicElectionSchema = createElectionSchema(totalLimit); const form = useForm<ElectionFormValues>({ resolver: zodResolver(dynamicElectionSchema), defaultValues: { candidates: {}, }, }); const handleOpenDialog = () => { setShowOtpDialog(true); setIsDialogOpen(true); }; const handleCloseDialog = (open: boolean) => { setIsDialogOpen(open); }; // Side effect to initialize form values when data changes useEffect(() => { if (!isLoading && data?.data?.votingCandidates) { const initialValues: Record<string, number> = {}; data.data.votingCandidates.forEach(candidate => { initialValues[candidate.candidateId.toString()] = candidate.ballotCounter || 0; }); form.setValue("candidates", initialValues); } }, [data?.data?.votingCandidates, isLoading]); const addVoteCandidatesMutation = useMutation(voteCandidatesOptions()); const [dialog, setDialog] = useState<DialogType>({ open: false, isSuccess: true, message: "", type: "none", }); const isMobileScreen = useMediaQuery({ maxWidth: 480 }); // Watch candidates form values and calculate total voted dynamically using useWatch (form.watch doesn't work) const watchedCandidates = useWatch({ control: form.control, name: "candidates", }) as Record<string, number> | undefined; const totalVoted = Object.values(watchedCandidates || {}).reduce( (sum, v) => sum + (typeof v === "number" ? v : Number(v || 0)), 0 ); const handleSubmit = async (data: ElectionFormValues) => { // Handle election form submission console.log("Election data:", data); if (totalVoted <= 0) { setDialog({ message: t("voteElection.election.error.minVote"), open: true, type: "error", isSuccess: false, }); return; } if (totalVoted < totalLimit) { setDialog({ message: t("voteElection.election.error.remainVote", { remaining: totalLimit - totalVoted, }), open: true, type: "none", isSuccess: true, }); return; } handleVerifyOtpBeforeSubmit(); }; const handleVerifyOtpBeforeSubmit = () => { if (isVerifyOtp) { sendOtpVerify.mutate(undefined, { onSuccess: function ({ statusCode, ticketId, errorInfo }) { if (isHttpStatusSuccess(statusCode)) { handleOpenDialog(); } else { showError( createErrorWithFallback( { errorInfo, statusCode, }, { details: ticketId || undefined, showErrorCode: true } ) ); } }, }); return; } mutationForm(); }; const onSubmit = form.handleSubmit(handleSubmit); const mutationForm = async (otp?: string) => { if (!data.data) return; const formData = form.getValues(); const { errorInfo, statusCode, ticketId } = await addVoteCandidatesMutation.mutateAsync({ batchId: data.data.batchId, batchType: data.data.batchType, candidates: Object.entries(formData.candidates).map( ([candidateId, ballotCounter]) => ({ candidateId: Number(candidateId), ballotCounter, }) ), otp, }); if (!errorInfo && isHttpStatusSuccess(statusCode)) { await invalidateBatchesQuery(); onShowResultDialog({ open: true, content: t("voteElection.election.success"), }); handleCloseDialog(false); onOpenChange(false); } else { showError( createErrorWithFallback( { errorInfo, statusCode, }, { details: ticketId || undefined, showErrorCode: true } ) ); } }; const candidates = (!isLoading && data?.data?.votingCandidates) || []; const [setCandidate, { add, has, remove, reset }] = useSet(new Set<number>()); // Initialize checkboxes for candidates with existing votes (ballotCounter > 0) useUpdateEffect(() => { if (!isLoading && candidates.length > 0) { // Clear existing selections first reset(); // Calculate candidates with votes > 0 const candidatesWithVotes = candidates.filter( candidate => candidate.ballotCounter > 0 ); if (candidatesWithVotes.length > 0) { // Calculate average votes per candidate with votes const avgVotesPerCandidate = Math.floor( totalLimit / candidatesWithVotes.length ); // Only check candidates whose ballotCounter equals the average candidates.forEach(candidate => { if (candidate.ballotCounter === avgVotesPerCandidate) { add(candidate.candidateId); } }); } // Initialize form values with existing ballotCounter values (preserve actual data) const formValues: Record<string, number> = {}; candidates.forEach(candidate => { formValues[candidate.candidateId.toString()] = candidate.ballotCounter || 0; }); form.setValue("candidates", formValues); } }, [candidates, isLoading, totalLimit]); // Handle manual checkbox changes by user interaction const handleCheckboxChange = (candidateId: number, checked: boolean) => { // compute previous size before mutating the set to avoid off-by-one const prevSize = setCandidate.size; if (checked) { add(candidateId); } else { remove(candidateId); } // Calculate new selection set size after the change const newSelectionSize = checked ? prevSize + 1 : Math.max(0, prevSize - 1); if (newSelectionSize > 0) { const avgVotes = Math.floor(totalLimit / newSelectionSize); const formValues: Record<string, number> = {}; candidates.forEach(candidate => { const willBeSelected = candidate.candidateId === candidateId ? checked : has(candidate.candidateId); formValues[candidate.candidateId.toString()] = willBeSelected ? avgVotes : 0; }); form.setValue("candidates", formValues); } else { // Reset all values to 0 when no candidates are selected const formValues: Record<string, number> = {}; candidates.forEach(candidate => { formValues[candidate.candidateId.toString()] = 0; }); form.setValue("candidates", formValues); } }; return ( <Form {...form}> {!isLoading ? ( data?.data ? ( <div className="flex flex-col gap-3.5 px-4 pt-2.5 pb-3"> <div className="flex flex-col gap-2"> <h2 className="responsive-text text-primary font-semibold text-left"> {t("voteElection.election.numElectionInfo")} </h2> <div className="flex max-sm:overflow-x-auto max-sm:overflow-y-hidden gap-3 pb-1"> <div className="max-sm:flex-shrink-0 max-w-40 xs:max-w-52 sm:max-w-64 lg:max-w-none"> <NumberOfShares title={t("shares.shareQtyConfirm")} count={ data?.data.candidateShareholder?.shareQtyConfirm || 0 } /> </div> <div className="max-sm:flex-shrink-0 max-w-40 xs:max-w-52 sm:max-w-64 lg:max-w-none"> <NumberOfShares title={t("shares.candidateCounter")} count={ data?.data.candidateShareholder?.candidateCounter || 0 } /> </div> <div className="max-sm:flex-shrink-0 max-w-40 xs:max-w-52 sm:max-w-64 lg:max-w-none"> <NumberOfShares title={t("shares.totalVotes")} count={data?.data.candidateShareholder?.totalVotes || 0} /> </div> </div> </div> <div className="flex flex-col gap-2 mb-4 rounded-lg"> <h2 className="responsive-text text-primary font-semibold text-left"> {t("voteElection.election.infoCandidate")} </h2> <Table className="text-left"> <TableHeader> <TableRow> <TableHead> {t("voteElection.election.table.candidateName", { tag: isMobileScreen ? <br key="br-1" /> : "", })} </TableHead> <TableHead className="text-center cursor-pointer" onClick={() => { if (setCandidate.size) { // Clear selection and reset form values reset(); form.reset(); } else { // Select all and distribute average votes candidates.forEach(i => add(i.candidateId)); const avgVotes = Math.floor( totalLimit / Math.max(1, candidates.length) ); const values: Record<string, number> = {}; candidates.forEach(c => { values[c.candidateId.toString()] = avgVotes; }); form.setValue("candidates", values); } }} > {t("voteElection.election.table.cumulativeVoting")}{" "} {isMobileScreen && <br key="br-2" />} </TableHead> <TableHead> {t("voteElection.election.table.numberVotes")} </TableHead> </TableRow> </TableHeader> <TableBody> {candidates.map(i => ( <TableRow key={i.candidateId}> <TableCell>{i.fullName}</TableCell> <TableCell className="text-center"> <Checkbox disabled={isDisabled} onCheckedChange={checked => { handleCheckboxChange( i.candidateId, checked === true ); }} checked={has(i.candidateId)} /> </TableCell> <TableCell> <FormField control={form.control} name={`candidates.${i.candidateId}`} render={({ field }) => ( <FormItem> <FormControl> <NumericFormat customInput={Input} placeholder={t( "voteElection.election.table.placeholder" )} className="px-3! py-1.5! min-w-28" disabled={!!setCandidate.size || isDisabled} value={field.value || ""} onValueChange={values => { const { floatValue } = values; field.onChange(floatValue || 0); }} thousandSeparator="," allowNegative={false} decimalScale={0} isAllowed={values => { const { floatValue } = values; return ( floatValue === undefined || floatValue >= 0 ); }} /> </FormControl> </FormItem> )} /> </TableCell> </TableRow> ))} </TableBody> <TableFooter> <TableRow> <TableCell className="text-foreground-05" colSpan={2}> {t("voteElection.election.table.totalVotes")} </TableCell> <TableCell className="text-foreground-05"> {totalVoted} </TableCell> </TableRow> </TableFooter> </Table> </div> </div> ) : ( <ErrorContent errorInfo={data?.errorInfo} className="m-4" /> ) ) : ( <div className="relative min-h-[400px]"> <Skeleton className="h-full rounded-default" /> <LoadingOverlay visible={isLoading} /> </div> )} <VotingElectionFooter onSubmit={onSubmit} isDisabled={isDisabled || isDialogOpen} batchId={data?.data?.batchId} batchName={ language === "vi" ? data?.data?.batchName : data?.data?.batchNameEn } batchType={data?.data?.batchType} /> {showOtpDialog && ( <Suspense fallback={<LoadingOverlay visible={true} />}> <LazyOtpVerificationDialog open={isDialogOpen} onOpenChange={handleCloseDialog} onConfirm={mutationForm} /> </Suspense> )} <CommonDialog open={dialog.open} onOpenChange={(open: boolean) => { if (!open && !dialog.isSuccess) form.clearErrors(); if (!open && dialog.isSuccess && dialog.type !== "none") onOpenChange(false); setDialog({ ...dialog, open }); }} rightButton={ dialog.isSuccess && dialog.type === "none" ? ( <Button className="flex-1" onClick={handleVerifyOtpBeforeSubmit}> {t("voteElection.confirm")} </Button> ) : null } content={dialog.message} iconType={dialog.type} className="md:max-w-sm!" /> </Form> ); }; const VotingItem = ({ item, index, form, length, isDisabled, }: { item: IssuesVoteDTO; index: number; form: UseFormReturn<VotingFormValues>; length: number; isDisabled: boolean; }) => { const { i18n, t } = useTranslation(["overview"]); const language = i18n.language as Language; const { content, description } = getLocalizedProperties( item, ["content", "description"], language ); return ( <div className={cn( "grid grid-cols-1 sm:grid-cols-2 gap-y-2 p-2.5 rounded-none bg-background-06", index < length - 1 && "border-b-2 border-background" )} > <div className="text-foreground-05 text-left flex flex-col gap-1.5"> <h3 className="responsive-text font-semibold">{content}</h3> <p className="responsive-text-sm">{description}</p> </div> <FormField disabled={isDisabled} control={form.control} name={`individualVotes.${item.issueId}`} render={({ field }) => ( <FormItem> <FormControl> <RadioGroup value={field.value} onValueChange={value => { // Clear batch vote when individual vote is changed form.setValue("batchVote", VOTE_OPTIONS.NONE); field.onChange(value); }} className="grid grid-cols-3 gap-5 justify-items-start sm:justify-items-end content-start" > <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.AGREE} id={`approve-${item.issueId}`} /> <Label htmlFor={`approve-${item.issueId}`} className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.agree")} </Label> </div> <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.DISAGREE} id={`disapprove-${item.issueId}`} /> <Label htmlFor={`disapprove-${item.issueId}`} className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.disagree")} </Label> </div> <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.NOIDEA} id={`abstain-${item.issueId}`} /> <Label htmlFor={`abstain-${item.issueId}`} className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.noOption")} </Label> </div> </RadioGroup> </FormControl> </FormItem> )} /> </div> ); }; const VotingElectionFooter = ({ onSubmit, isDisabled, batchId, batchName, batchType, }: { onSubmit?: ( e?: BaseSyntheticEvent<object, any, any> | undefined ) => Promise<void>; isDisabled: boolean; batchId?: string; batchName?: string; batchType?: BatchType; }) => { const form = useFormContext(); const [isLoading, setIsLoading] = useState(false); const { showError } = useError(); const isSubmitting = form.formState.isSubmitting; const { t } = useTranslation(["overview"]); const { i18n } = useTranslation(); const language = i18n.language as Language; const queryClient = useQueryClient(); const [dialog, setDialog] = useState<{ open: boolean; data: MeetingInfoDTO[]; }>({ data: [], open: false, }); const handleFetchGuide = async () => { if (!batchId) return alert("Missing batchId"); const data = await queryClient.fetchQuery( informationQueryOptions(INFO_TYPES.TAILIEU, batchId) ); setDialog({ open: true, data: data.data || [] }); }; const handleDownloadPdf = async (batchId?: string) => { try { if (!batchId) { alert("Missing batchId"); return; } setIsLoading(true); const isVoting = batchType === "BIEUQUYET"; const blobData = await queryClient.fetchQuery( isVoting ? downloadVotingPdf(batchId) : downloadElectionPdf(batchId) ); // The API returns Uint8Array for non-JSON responses (PDFs, etc.) const blob = new Blob([blobData], { type: "application/pdf", }); const blobUrl = URL.createObjectURL(blob); // Create a temporary anchor element to trigger download const link = document.createElement("a"); link.href = blobUrl; const linkName = language === "vi" ? isVoting ? "Giấy biểu quyết" : "Giấy bầu cử" : isVoting ? "Voting form" : "Election form"; link.download = `${linkName} ${batchName}.pdf`; document.body.appendChild(link); link.click(); // Cleanup document.body.removeChild(link); URL.revokeObjectURL(blobUrl); } catch (error) { showError( createErrorWithFallback( error as { errorInfo?: { errorCode?: string; errorMessageVI?: string; errorMessageEN?: string; }; statusCode?: number; }, { showErrorCode: true, } ) ); } finally { setIsLoading(false); } }; return ( <div className="flex flex-col xs:flex-row gap-x-6 gap-y-3 px-4 pb-3"> <div className="flex gap-3 items-center"> <Button type="button" variant="secondary" className="max-h-8 xs:max-h-[unset]" disabled={isLoading} onClick={() => handleDownloadPdf(batchId)} > {!isLoading ? <DownloadIcon /> : <Loader className="animate-spin" />} {t("voteElection.downloadBallot")} </Button> <Button type="button" variant="secondary" className="max-h-8 xs:max-h-[unset]" onClick={handleFetchGuide} > <DocumentIcon className="[&>path]:stroke-primary" /> {t("voteElection.manual")} </Button> </div> {onSubmit && ( <Button type="submit" onClick={onSubmit} className="xs:flex-1" disabled={isDisabled || isSubmitting} > {isSubmitting ? ( <div className="flex items-center justify-center gap-2"> <Loader className="animate-spin flex-1" /> <span>{t("voteElection.confirm") + "..."}</span> </div> ) : ( t("voteElection.confirm") )} </Button> )} <LazyMeetingDocumentDialog open={dialog.open} onOpenChange={open => { if (!open) setDialog({ ...dialog, open }); }} meetingInfos={dialog.data} /> </div> ); }; export default VotingElectionDialog;import CommonDialog, { type IconType } from "@/components/dialog"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { Form, FormControl, FormField, FormItem } from "@/components/ui/form"; import { Drawer, DrawerClose, DrawerContent, DrawerHeader, DrawerTitle, } from "@/components/ui/drawer"; import DocumentIcon from "@/icons/document-icon"; import DownloadIcon from "@/icons/download-icon"; import HourglassEagerIcon from "@/icons/hourglass-eager-icon"; import { cn } from "@/lib/utils"; import { useForm, useFormContext, useWatch, type UseFormReturn, } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import type { BaseSyntheticEvent } from "react"; import { lazy, Suspense, useEffect, useState } from "react"; import NumberOfShares from "./number-of-shares"; import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { useSet, useUpdateEffect } from "react-use"; import { ScrollArea } from "@/components/ui/scroll-area"; import { NumericFormat } from "react-number-format"; import type { BatchCandidatesResponse, BatchIssuesResponse, IssuesBatchResponse, IssuesVoteDTO, MeetingInfoDTO, } from "@/types/information.type"; import { BATCH_TYPES, INFO_TYPES, VOTE_OPTIONS, type BatchType, type VoteOption, type VoteStatus, } from "@/constants/params"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { candidatesByBatchIdQueryOptions, informationQueryOptions, issuesByBatchIdQueryOptions, } from "@/features/information/information.api"; import { getLocalizedProperties, isHttpStatusSuccess } from "@/lib/helper"; import { useTranslation } from "react-i18next"; import type { Language } from "@/constants/language"; import { format } from "date-fns"; import { TIME_FORMAT } from "@/constants/format"; import type { ApiResponse } from "@/types/api.type"; import { Skeleton } from "@/components/ui/skeleton"; import { LoadingOverlay } from "@/components/ui/loading-overlay"; import { useMediaQuery } from "react-responsive"; import CancelIcon from "@/icons/cancel-icon"; import NoContent from "@/components/no-content"; import ErrorContent from "@/components/error-content"; import { downloadElectionPdf, downloadVotingPdf, voteCandidatesOptions, voteIssuesOptions, } from "@/features/meeting/meeting.api"; import { useError } from "@/contexts/error-provider"; import { createErrorWithFallback } from "@/lib/error-utils"; import type { RefetchProps } from "../$meetId/-components/batch-list"; import useAuthApi from "@/features/auth/auth.api"; import { Loader } from "lucide-react"; const LazyMeetingDocumentDialog = lazy( () => import("@/components/meeting-document-dialog") ); const LazyOtpVerificationDialog = lazy(() => import("@/components/otp-dialog")); interface IProps { open: boolean; onOpenChange: (open: boolean) => void; batch: IssuesBatchResponse; onShowResultDialog: (dialog: { open: boolean; content: string }) => void; refetchBatches: RefetchProps; } type DialogType = { open: boolean; isSuccess: boolean; message: React.ReactNode; type: IconType; }; export function getBadgeColorByStatus(status: VoteStatus) { switch (status) { case "CHOBOPHIEU": return "bg-primary-03 text-white"; case "DABOPHIEU": return "bg-primary text-white"; default: return "bg-background-07 text-foreground-09 [&_svg_path]:!fill-[#c5c5c5] [&_svg_path]:!stroke-[#c5c5c5]"; } } // Function to create election schema with dynamic total limit const createElectionSchema = (totalLimit: number) => z .object({ candidates: z.record( z.string(), z.number().min(0, "voteElection.election.error.minVote") ), }) .refine( data => { const total = Object.values(data.candidates).reduce( (sum, votes) => sum + votes, 0 ); return total <= totalLimit; }, { message: "voteElection.election.error.maxVote", path: ["candidates"], } ); type ElectionFormValues = z.infer<ReturnType<typeof createElectionSchema>>; // Form schema for voting const votingSchema = z.object({ batchVote: z .enum([ VOTE_OPTIONS.AGREE, VOTE_OPTIONS.DISAGREE, VOTE_OPTIONS.NOIDEA, VOTE_OPTIONS.NONE, VOTE_OPTIONS.INVALID, ]) .optional(), individualVotes: z .record( z.string(), z.enum([ VOTE_OPTIONS.AGREE, VOTE_OPTIONS.DISAGREE, VOTE_OPTIONS.NOIDEA, VOTE_OPTIONS.NONE, VOTE_OPTIONS.INVALID, ]) ) .refine( record => { const selectedValues = Object.values(record); // Return expected result return selectedValues.every(value => value !== VOTE_OPTIONS.NONE); }, { error: "voteElection.vote.error" } ), }); type VotingFormValues = z.infer<typeof votingSchema>; const VotingElectionDialog = ({ open, onOpenChange, batch, onShowResultDialog, refetchBatches, }: IProps) => { const isElection = batch.batchType !== BATCH_TYPES.BIEUQUYET; const isMobile = useMediaQuery({ maxWidth: 768 }); // Use separate queries for different batch types - only run when dialog is actually open const issuesOptions = issuesByBatchIdQueryOptions(batch.batchId); const issuesQuery = useQuery({ ...issuesOptions, enabled: !isElection && open, queryKey: [...issuesOptions.queryKey, String(open)], // Refetch when dialog opens refetchOnMount: false, }); const candidatesOptions = candidatesByBatchIdQueryOptions(batch.batchId); const candidatesQuery = useQuery({ ...candidatesOptions, enabled: isElection && open, queryKey: [...candidatesOptions.queryKey, String(open)], // Refetch when dialog opens refetchOnMount: false, }); const { i18n } = useTranslation(); const language = i18n.language as Language; const data = isElection ? candidatesQuery.data : issuesQuery.data; const isLoading = isElection ? candidatesQuery.isLoading : issuesQuery.isLoading; const { batchName } = getLocalizedProperties(batch, ["batchName"], language); const contentComponent = !isElection ? ( <VotingContent onOpenChange={onOpenChange} isLoading={isLoading} data={data as ApiResponse<BatchIssuesResponse | null>} onShowResultDialog={onShowResultDialog} invalidateBatchesQuery={refetchBatches} /> ) : ( <ElectionContent onOpenChange={onOpenChange} data={data as ApiResponse<BatchCandidatesResponse | null>} isLoading={isLoading} onShowResultDialog={onShowResultDialog} invalidateBatchesQuery={refetchBatches} /> ); const headerComponent = <VotingElectionHeader batch={batch} />; if (isMobile) { return ( <Drawer open={open} onOpenChange={onOpenChange}> <DrawerContent className="max-h-[90vh]!"> <DrawerHeader className="border-b-2 border-background-02 gap-2"> <div className="flex items-center justify-between"> <DrawerTitle className="text-left text-foreground-12 font-bold responsive-text-3xl"> {batchName} </DrawerTitle> <DrawerClose asChild> <Button variant="ghost" className="size-6 p-0!"> <CancelIcon className="size-6" /> <span className="sr-only">Close</span> </Button> </DrawerClose> </div> {headerComponent} </DrawerHeader> <div className="flex-1 overflow-auto">{contentComponent}</div> </DrawerContent> </Drawer> ); } return ( <CommonDialog noTrans title={batchName} content={contentComponent} open={open} className={cn( "max-w-[360px] xs:max-w-[600px] sm:max-w-[800px] gap-0", "p-0! [&>[data-slot='dialog-header']]:p-4 [&>[data-slot='dialog-header']]:border-b-2 [&>[data-slot='dialog-header']]:border-background-02 [&>[data-slot='dialog-close']]:top-4 [&>[data-slot='dialog-close']]:right-4" )} showCloseBtn={false} onOpenChange={onOpenChange} header={headerComponent} /> ); }; const VotingElectionHeader = ({ batch }: { batch: IssuesBatchResponse }) => { const { t } = useTranslation(["overview"]); return ( <div className="flex items-stretch justify-between"> <Badge variant="outlineDestructive" className="h-[unset]"> <HourglassEagerIcon className="!size-4" /> {t("voteElection.endTime", { endTime: batch.endTime && format(batch.endTime, TIME_FORMAT), })} </Badge> <Button isSmall variant="secondary" className={cn( "max-h-8 p-1.5 responsive-text-sm", getBadgeColorByStatus(batch.votingStatus) )} > {batch.votingStatus == "CHOBOPHIEU" ? t("voteElection.status.waiting") : batch.votingStatus == "DABOPHIEU" ? t("voteElection.status.voted") : t("voteElection.status.closed")} </Button> </div> ); }; const VotingContent = ({ onOpenChange, isLoading, data, onShowResultDialog, invalidateBatchesQuery, }: { onOpenChange: (open: boolean) => void; isLoading: boolean; data: ApiResponse<BatchIssuesResponse | null>; onShowResultDialog: (open: { open: boolean; content: string }) => void; invalidateBatchesQuery: RefetchProps; }) => { const { sendOtpVerify } = useAuthApi(); const { showError } = useError(); const { t, i18n } = useTranslation(["overview"]); const [showOtpDialog, setShowOtpDialog] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false); const language = i18n.language as Language; const form = useForm<VotingFormValues>({ resolver: zodResolver(votingSchema), defaultValues: { batchVote: VOTE_OPTIONS.NONE, individualVotes: {}, }, }); const isVerifyOtp = data?.data?.isRequestOtp; const isDisabled = !data?.data?.isAllowedToVote; const handleOpenDialog = () => { setShowOtpDialog(true); setIsDialogOpen(true); }; const handleCloseDialog = (open: boolean) => { setIsDialogOpen(open); }; // Side effect to initialize form values when data changes useEffect(() => { if (data?.data?.votingIssues) { const initialVotes = data.data.votingIssues.reduce< Record<string, VoteOption> >((acc, cur) => { acc[cur.issueId.toString()] = cur.eVote; return acc; }, {}); // Determine initial batch vote const votes = Object.values(initialVotes); const initialBatchVote = votes.length > 0 && votes.every(vote => vote === votes[0] && vote !== VOTE_OPTIONS.NONE) ? votes[0] : VOTE_OPTIONS.NONE; // Update form values form.setValue("individualVotes", initialVotes); form.setValue("batchVote", initialBatchVote); } }, [data?.data?.votingIssues, form]); const addVoteIssuesMutation = useMutation(voteIssuesOptions()); const [dialog, setDialog] = useState<DialogType>({ open: false, isSuccess: true, message: "", type: "none", }); const handleSubmit = async (params: VotingFormValues) => { // Handle voting form submission console.log("Voting data:", params); if (isVerifyOtp) { sendOtpVerify.mutate(undefined, { onSuccess: function ({ statusCode, ticketId, errorInfo }) { if (isHttpStatusSuccess(statusCode)) { handleOpenDialog(); } else { showError( createErrorWithFallback( { errorInfo, statusCode, }, { details: ticketId || undefined, showErrorCode: true } ) ); } }, }); return; } mutationForm(); }; const onSubmit = form.handleSubmit(handleSubmit); const mutationForm = async (otp?: string) => { if (!data.data) return; const { errorInfo, statusCode, ticketId } = await addVoteIssuesMutation.mutateAsync({ batchId: data.data.batchId, shareQtyConfirm: data.data.shareQtyConfirm, issues: Object.entries(form.getValues().individualVotes).map( ([issueId, vote]) => ({ issueId: Number(issueId), eVote: vote, }) ), otp, }); if (!errorInfo && isHttpStatusSuccess(statusCode)) { await invalidateBatchesQuery(); onShowResultDialog({ open: true, content: t("voteElection.vote.success"), }); handleCloseDialog(false); onOpenChange(false); } else { showError( createErrorWithFallback( { errorInfo, statusCode, }, { details: ticketId || undefined, showErrorCode: true } ) ); } }; // Watch for batchVote changes and update all individual votes const batchVote = useWatch({ control: form.control, name: "batchVote" }); useEffect(() => { // Only update individual votes when batchVote has a valid value (not empty string) if (batchVote !== VOTE_OPTIONS.NONE && batchVote !== undefined) { const newIndividualVotes = data.data?.votingIssues?.reduce( (acc, item) => { acc[item.issueId.toString()] = batchVote; return acc; }, {} as Record<string, VoteOption> ); if (newIndividualVotes) { form.setValue("individualVotes", newIndividualVotes); } } }, [batchVote, form]); // Handle form errors useEffect(() => { if (Object.keys(form.formState.errors).length > 0) { // Handle errors here const errors = Object.values(form.formState.errors); const message = errors.map((err, i) => { const code = err.message?.toString() || err.root?.message; return ( <p key={i}> {errors.length > 1 && "• "} {t(code)} </p> ); }); setDialog({ open: true, message, isSuccess: false, type: "error" }); } }, [form.formState.errors]); return ( <Form {...form}> <div className="px-4 py-3.5"> <div className="flex flex-col gap-3"> {/* Bỏ phiếu hàng loạt */} <div className="grid grid-cols-1 gap-y-2 xs:grid-cols-3 sm:grid-cols-2 px-2.5"> <h2 className="responsive-text text-primary font-semibold text-left"> {t("voteElection.vote.title")} </h2> <FormField disabled={isDisabled} control={form.control} name="batchVote" render={({ field }) => ( <FormItem className="xs:col-span-2 sm:col-span-1"> <FormControl> <RadioGroup value={field.value} onValueChange={field.onChange} className="grid grid-cols-3 gap-5 justify-items-start xs:justify-items-end" > <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.AGREE} id="batch-approve" /> <Label htmlFor="batch-approve" className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.agree")} </Label> </div> <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.DISAGREE} id="batch-disapprove" /> <Label htmlFor="batch-disapprove" className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.disagree")} </Label> </div> <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.NOIDEA} id="batch-abstain" /> <Label htmlFor="batch-abstain" className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.noOption")} </Label> </div> </RadioGroup> </FormControl> </FormItem> )} /> </div> {/* Bỏ phiếu từng biểu quyết */} {!isLoading ? ( data?.data ? ( <ScrollArea className="rounded-default"> {data.data.votingIssues ? ( data.data.votingIssues?.map((item, index) => ( <VotingItem key={item.issueId} item={item} isDisabled={isDisabled} length={data.data?.votingIssues?.length || 0} index={index} form={form} /> )) ) : ( <NoContent /> )} </ScrollArea> ) : ( <ErrorContent errorInfo={data?.errorInfo} className="m-4" /> ) ) : ( <div className="relative min-h-[150px]"> <Skeleton className="h-full rounded-default" /> <LoadingOverlay visible={isLoading} /> </div> )} </div> </div> <VotingElectionFooter onSubmit={onSubmit} isDisabled={isDisabled || isDialogOpen} batchId={data?.data?.batchId} batchName={ language === "vi" ? data?.data?.batchName : data?.data?.batchNameEn } batchType={data?.data?.batchType} /> <CommonDialog className="md:max-w-sm!" open={dialog.open} onOpenChange={(open: boolean) => { if (!open && !dialog.isSuccess) form.clearErrors(); if (!open && dialog.isSuccess && dialog.type !== "none") onOpenChange(false); setDialog({ ...dialog, open }); }} content={dialog.message} iconType={dialog.isSuccess ? "success" : "error"} /> {showOtpDialog && ( <Suspense fallback={<LoadingOverlay visible={true} />}> <LazyOtpVerificationDialog open={isDialogOpen} onOpenChange={handleCloseDialog} onConfirm={mutationForm} /> </Suspense> )} </Form> ); }; const ElectionContent = ({ onOpenChange, data, isLoading, onShowResultDialog, invalidateBatchesQuery, }: { onOpenChange: (open: boolean) => void; data: ApiResponse<BatchCandidatesResponse | null>; isLoading: boolean; onShowResultDialog: (open: { open: boolean; content: string }) => void; invalidateBatchesQuery: RefetchProps; }) => { const { sendOtpVerify } = useAuthApi(); const { showError } = useError(); const { t, i18n } = useTranslation(["overview"]); const [showOtpDialog, setShowOtpDialog] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false); const language = i18n.language as Language; const isVerifyOtp = data?.data?.isRequestOtp; // Safe access to data with loading check const totalLimit = (!isLoading && data?.data?.candidateShareholder?.totalVotes) || 0; const isDisabled = !data?.data?.isAllowedToVote; // Create dynamic schema with the total limit from API const dynamicElectionSchema = createElectionSchema(totalLimit); const form = useForm<ElectionFormValues>({ resolver: zodResolver(dynamicElectionSchema), defaultValues: { candidates: {}, }, }); const handleOpenDialog = () => { setShowOtpDialog(true); setIsDialogOpen(true); }; const handleCloseDialog = (open: boolean) => { setIsDialogOpen(open); }; // Side effect to initialize form values when data changes useEffect(() => { if (!isLoading && data?.data?.votingCandidates) { const initialValues: Record<string, number> = {}; data.data.votingCandidates.forEach(candidate => { initialValues[candidate.candidateId.toString()] = candidate.ballotCounter || 0; }); form.setValue("candidates", initialValues); } }, [data?.data?.votingCandidates, isLoading]); const addVoteCandidatesMutation = useMutation(voteCandidatesOptions()); const [dialog, setDialog] = useState<DialogType>({ open: false, isSuccess: true, message: "", type: "none", }); const isMobileScreen = useMediaQuery({ maxWidth: 480 }); // Watch candidates form values and calculate total voted dynamically using useWatch (form.watch doesn't work) const watchedCandidates = useWatch({ control: form.control, name: "candidates", }) as Record<string, number> | undefined; const totalVoted = Object.values(watchedCandidates || {}).reduce( (sum, v) => sum + (typeof v === "number" ? v : Number(v || 0)), 0 ); const handleSubmit = async (data: ElectionFormValues) => { // Handle election form submission console.log("Election data:", data); if (totalVoted <= 0) { setDialog({ message: t("voteElection.election.error.minVote"), open: true, type: "error", isSuccess: false, }); return; } if (totalVoted < totalLimit) { setDialog({ message: t("voteElection.election.error.remainVote", { remaining: totalLimit - totalVoted, }), open: true, type: "none", isSuccess: true, }); return; } handleVerifyOtpBeforeSubmit(); }; const handleVerifyOtpBeforeSubmit = () => { if (isVerifyOtp) { sendOtpVerify.mutate(undefined, { onSuccess: function ({ statusCode, ticketId, errorInfo }) { if (isHttpStatusSuccess(statusCode)) { handleOpenDialog(); } else { showError( createErrorWithFallback( { errorInfo, statusCode, }, { details: ticketId || undefined, showErrorCode: true } ) ); } }, }); return; } mutationForm(); }; const onSubmit = form.handleSubmit(handleSubmit); const mutationForm = async (otp?: string) => { if (!data.data) return; const formData = form.getValues(); const { errorInfo, statusCode, ticketId } = await addVoteCandidatesMutation.mutateAsync({ batchId: data.data.batchId, batchType: data.data.batchType, candidates: Object.entries(formData.candidates).map( ([candidateId, ballotCounter]) => ({ candidateId: Number(candidateId), ballotCounter, }) ), otp, }); if (!errorInfo && isHttpStatusSuccess(statusCode)) { await invalidateBatchesQuery(); onShowResultDialog({ open: true, content: t("voteElection.election.success"), }); handleCloseDialog(false); onOpenChange(false); } else { showError( createErrorWithFallback( { errorInfo, statusCode, }, { details: ticketId || undefined, showErrorCode: true } ) ); } }; const candidates = (!isLoading && data?.data?.votingCandidates) || []; const [setCandidate, { add, has, remove, reset }] = useSet(new Set<number>()); // Initialize checkboxes for candidates with existing votes (ballotCounter > 0) useUpdateEffect(() => { if (!isLoading && candidates.length > 0) { // Clear existing selections first reset(); // Calculate candidates with votes > 0 const candidatesWithVotes = candidates.filter( candidate => candidate.ballotCounter > 0 ); if (candidatesWithVotes.length > 0) { // Calculate average votes per candidate with votes const avgVotesPerCandidate = Math.floor( totalLimit / candidatesWithVotes.length ); // Only check candidates whose ballotCounter equals the average candidates.forEach(candidate => { if (candidate.ballotCounter === avgVotesPerCandidate) { add(candidate.candidateId); } }); } // Initialize form values with existing ballotCounter values (preserve actual data) const formValues: Record<string, number> = {}; candidates.forEach(candidate => { formValues[candidate.candidateId.toString()] = candidate.ballotCounter || 0; }); form.setValue("candidates", formValues); } }, [candidates, isLoading, totalLimit]); // Handle manual checkbox changes by user interaction const handleCheckboxChange = (candidateId: number, checked: boolean) => { // compute previous size before mutating the set to avoid off-by-one const prevSize = setCandidate.size; if (checked) { add(candidateId); } else { remove(candidateId); } // Calculate new selection set size after the change const newSelectionSize = checked ? prevSize + 1 : Math.max(0, prevSize - 1); if (newSelectionSize > 0) { const avgVotes = Math.floor(totalLimit / newSelectionSize); const formValues: Record<string, number> = {}; candidates.forEach(candidate => { const willBeSelected = candidate.candidateId === candidateId ? checked : has(candidate.candidateId); formValues[candidate.candidateId.toString()] = willBeSelected ? avgVotes : 0; }); form.setValue("candidates", formValues); } else { // Reset all values to 0 when no candidates are selected const formValues: Record<string, number> = {}; candidates.forEach(candidate => { formValues[candidate.candidateId.toString()] = 0; }); form.setValue("candidates", formValues); } }; return ( <Form {...form}> {!isLoading ? ( data?.data ? ( <div className="flex flex-col gap-3.5 px-4 pt-2.5 pb-3"> <div className="flex flex-col gap-2"> <h2 className="responsive-text text-primary font-semibold text-left"> {t("voteElection.election.numElectionInfo")} </h2> <div className="flex max-sm:overflow-x-auto max-sm:overflow-y-hidden gap-3 pb-1"> <div className="max-sm:flex-shrink-0 max-w-40 xs:max-w-52 sm:max-w-64 lg:max-w-none"> <NumberOfShares title={t("shares.shareQtyConfirm")} count={ data?.data.candidateShareholder?.shareQtyConfirm || 0 } /> </div> <div className="max-sm:flex-shrink-0 max-w-40 xs:max-w-52 sm:max-w-64 lg:max-w-none"> <NumberOfShares title={t("shares.candidateCounter")} count={ data?.data.candidateShareholder?.candidateCounter || 0 } /> </div> <div className="max-sm:flex-shrink-0 max-w-40 xs:max-w-52 sm:max-w-64 lg:max-w-none"> <NumberOfShares title={t("shares.totalVotes")} count={data?.data.candidateShareholder?.totalVotes || 0} /> </div> </div> </div> <div className="flex flex-col gap-2 mb-4 rounded-lg"> <h2 className="responsive-text text-primary font-semibold text-left"> {t("voteElection.election.infoCandidate")} </h2> <Table className="text-left"> <TableHeader> <TableRow> <TableHead> {t("voteElection.election.table.candidateName", { tag: isMobileScreen ? <br key="br-1" /> : "", })} </TableHead> <TableHead className="text-center cursor-pointer" onClick={() => { if (setCandidate.size) { // Clear selection and reset form values reset(); form.reset(); } else { // Select all and distribute average votes candidates.forEach(i => add(i.candidateId)); const avgVotes = Math.floor( totalLimit / Math.max(1, candidates.length) ); const values: Record<string, number> = {}; candidates.forEach(c => { values[c.candidateId.toString()] = avgVotes; }); form.setValue("candidates", values); } }} > {t("voteElection.election.table.cumulativeVoting")}{" "} {isMobileScreen && <br key="br-2" />} </TableHead> <TableHead> {t("voteElection.election.table.numberVotes")} </TableHead> </TableRow> </TableHeader> <TableBody> {candidates.map(i => ( <TableRow key={i.candidateId}> <TableCell>{i.fullName}</TableCell> <TableCell className="text-center"> <Checkbox disabled={isDisabled} onCheckedChange={checked => { handleCheckboxChange( i.candidateId, checked === true ); }} checked={has(i.candidateId)} /> </TableCell> <TableCell> <FormField control={form.control} name={`candidates.${i.candidateId}`} render={({ field }) => ( <FormItem> <FormControl> <NumericFormat customInput={Input} placeholder={t( "voteElection.election.table.placeholder" )} className="px-3! py-1.5! min-w-28" disabled={!!setCandidate.size || isDisabled} value={field.value || ""} onValueChange={values => { const { floatValue } = values; field.onChange(floatValue || 0); }} thousandSeparator="," allowNegative={false} decimalScale={0} isAllowed={values => { const { floatValue } = values; return ( floatValue === undefined || floatValue >= 0 ); }} /> </FormControl> </FormItem> )} /> </TableCell> </TableRow> ))} </TableBody> <TableFooter> <TableRow> <TableCell className="text-foreground-05" colSpan={2}> {t("voteElection.election.table.totalVotes")} </TableCell> <TableCell className="text-foreground-05"> {totalVoted} </TableCell> </TableRow> </TableFooter> </Table> </div> </div> ) : ( <ErrorContent errorInfo={data?.errorInfo} className="m-4" /> ) ) : ( <div className="relative min-h-[400px]"> <Skeleton className="h-full rounded-default" /> <LoadingOverlay visible={isLoading} /> </div> )} <VotingElectionFooter onSubmit={onSubmit} isDisabled={isDisabled || isDialogOpen} batchId={data?.data?.batchId} batchName={ language === "vi" ? data?.data?.batchName : data?.data?.batchNameEn } batchType={data?.data?.batchType} /> {showOtpDialog && ( <Suspense fallback={<LoadingOverlay visible={true} />}> <LazyOtpVerificationDialog open={isDialogOpen} onOpenChange={handleCloseDialog} onConfirm={mutationForm} /> </Suspense> )} <CommonDialog open={dialog.open} onOpenChange={(open: boolean) => { if (!open && !dialog.isSuccess) form.clearErrors(); if (!open && dialog.isSuccess && dialog.type !== "none") onOpenChange(false); setDialog({ ...dialog, open }); }} rightButton={ dialog.isSuccess && dialog.type === "none" ? ( <Button className="flex-1" onClick={handleVerifyOtpBeforeSubmit}> {t("voteElection.confirm")} </Button> ) : null } content={dialog.message} iconType={dialog.type} className="md:max-w-sm!" /> </Form> ); }; const VotingItem = ({ item, index, form, length, isDisabled, }: { item: IssuesVoteDTO; index: number; form: UseFormReturn<VotingFormValues>; length: number; isDisabled: boolean; }) => { const { i18n, t } = useTranslation(["overview"]); const language = i18n.language as Language; const { content, description } = getLocalizedProperties( item, ["content", "description"], language ); return ( <div className={cn( "grid grid-cols-1 sm:grid-cols-2 gap-y-2 p-2.5 rounded-none bg-background-06", index < length - 1 && "border-b-2 border-background" )} > <div className="text-foreground-05 text-left flex flex-col gap-1.5"> <h3 className="responsive-text font-semibold">{content}</h3> <p className="responsive-text-sm">{description}</p> </div> <FormField disabled={isDisabled} control={form.control} name={`individualVotes.${item.issueId}`} render={({ field }) => ( <FormItem> <FormControl> <RadioGroup value={field.value} onValueChange={value => { // Clear batch vote when individual vote is changed form.setValue("batchVote", VOTE_OPTIONS.NONE); field.onChange(value); }} className="grid grid-cols-3 gap-5 justify-items-start sm:justify-items-end content-start" > <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.AGREE} id={`approve-${item.issueId}`} /> <Label htmlFor={`approve-${item.issueId}`} className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.agree")} </Label> </div> <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.DISAGREE} id={`disapprove-${item.issueId}`} /> <Label htmlFor={`disapprove-${item.issueId}`} className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.disagree")} </Label> </div> <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.NOIDEA} id={`abstain-${item.issueId}`} /> <Label htmlFor={`abstain-${item.issueId}`} className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.noOption")} </Label> </div> </RadioGroup> </FormControl> </FormItem> )} /> </div> ); }; const VotingElectionFooter = ({ onSubmit, isDisabled, batchId, batchName, batchType, }: { onSubmit?: ( e?: BaseSyntheticEvent<object, any, any> | undefined ) => Promise<void>; isDisabled: boolean; batchId?: string; batchName?: string; batchType?: BatchType; }) => { const form = useFormContext(); const [isLoading, setIsLoading] = useState(false); const { showError } = useError(); const isSubmitting = form.formState.isSubmitting; const { t } = useTranslation(["overview"]); const { i18n } = useTranslation(); const language = i18n.language as Language; const queryClient = useQueryClient(); const [dialog, setDialog] = useState<{ open: boolean; data: MeetingInfoDTO[]; }>({ data: [], open: false, }); const handleFetchGuide = async () => { if (!batchId) return alert("Missing batchId"); const data = await queryClient.fetchQuery( informationQueryOptions(INFO_TYPES.TAILIEU, batchId) ); setDialog({ open: true, data: data.data || [] }); }; const handleDownloadPdf = async (batchId?: string) => { try { if (!batchId) { alert("Missing batchId"); return; } setIsLoading(true); const isVoting = batchType === "BIEUQUYET"; const blobData = await queryClient.fetchQuery( isVoting ? downloadVotingPdf(batchId) : downloadElectionPdf(batchId) ); // The API returns Uint8Array for non-JSON responses (PDFs, etc.) const blob = new Blob([blobData], { type: "application/pdf", }); const blobUrl = URL.createObjectURL(blob); // Create a temporary anchor element to trigger download const link = document.createElement("a"); link.href = blobUrl; const linkName = language === "vi" ? isVoting ? "Giấy biểu quyết" : "Giấy bầu cử" : isVoting ? "Voting form" : "Election form"; link.download = `${linkName} ${batchName}.pdf`; document.body.appendChild(link); link.click(); // Cleanup document.body.removeChild(link); URL.revokeObjectURL(blobUrl); } catch (error) { showError( createErrorWithFallback( error as { errorInfo?: { errorCode?: string; errorMessageVI?: string; errorMessageEN?: string; }; statusCode?: number; }, { showErrorCode: true, } ) ); } finally { setIsLoading(false); } }; return ( <div className="flex flex-col xs:flex-row gap-x-6 gap-y-3 px-4 pb-3"> <div className="flex gap-3 items-center"> <Button type="button" variant="secondary" className="max-h-8 xs:max-h-[unset]" disabled={isLoading} onClick={() => handleDownloadPdf(batchId)} > {!isLoading ? <DownloadIcon /> : <Loader className="animate-spin" />} {t("voteElection.downloadBallot")} </Button> <Button type="button" variant="secondary" className="max-h-8 xs:max-h-[unset]" onClick={handleFetchGuide} > <DocumentIcon className="[&>path]:stroke-primary" /> {t("voteElection.manual")} </Button> </div> {onSubmit && ( <Button type="submit" onClick={onSubmit} className="xs:flex-1" disabled={isDisabled || isSubmitting} > {isSubmitting ? ( <div className="flex items-center justify-center gap-2"> <Loader className="animate-spin flex-1" /> <span>{t("voteElection.confirm") + "..."}</span> </div> ) : ( t("voteElection.confirm") )} </Button> )} <LazyMeetingDocumentDialog open={dialog.open} onOpenChange={open => { if (!open) setDialog({ ...dialog, open }); }} meetingInfos={dialog.data} /> </div> ); }; export default VotingElectionDialog;import CommonDialog, { type IconType } from "@/components/dialog"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { Form, FormControl, FormField, FormItem } from "@/components/ui/form"; import { Drawer, DrawerClose, DrawerContent, DrawerHeader, DrawerTitle, } from "@/components/ui/drawer"; import DocumentIcon from "@/icons/document-icon"; import DownloadIcon from "@/icons/download-icon"; import HourglassEagerIcon from "@/icons/hourglass-eager-icon"; import { cn } from "@/lib/utils"; import { useForm, useFormContext, useWatch, type UseFormReturn, } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import type { BaseSyntheticEvent } from "react"; import { lazy, Suspense, useEffect, useState } from "react"; import NumberOfShares from "./number-of-shares"; import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { useSet, useUpdateEffect } from "react-use"; import { ScrollArea } from "@/components/ui/scroll-area"; import { NumericFormat } from "react-number-format"; import type { BatchCandidatesResponse, BatchIssuesResponse, IssuesBatchResponse, IssuesVoteDTO, MeetingInfoDTO, } from "@/types/information.type"; import { BATCH_TYPES, INFO_TYPES, VOTE_OPTIONS, type BatchType, type VoteOption, type VoteStatus, } from "@/constants/params"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { candidatesByBatchIdQueryOptions, informationQueryOptions, issuesByBatchIdQueryOptions, } from "@/features/information/information.api"; import { getLocalizedProperties, isHttpStatusSuccess } from "@/lib/helper"; import { useTranslation } from "react-i18next"; import type { Language } from "@/constants/language"; import { format } from "date-fns"; import { TIME_FORMAT } from "@/constants/format"; import type { ApiResponse } from "@/types/api.type"; import { Skeleton } from "@/components/ui/skeleton"; import { LoadingOverlay } from "@/components/ui/loading-overlay"; import { useMediaQuery } from "react-responsive"; import CancelIcon from "@/icons/cancel-icon"; import NoContent from "@/components/no-content"; import ErrorContent from "@/components/error-content"; import { downloadElectionPdf, downloadVotingPdf, voteCandidatesOptions, voteIssuesOptions, } from "@/features/meeting/meeting.api"; import { useError } from "@/contexts/error-provider"; import { createErrorWithFallback } from "@/lib/error-utils"; import type { RefetchProps } from "../$meetId/-components/batch-list"; import useAuthApi from "@/features/auth/auth.api"; import { Loader } from "lucide-react"; const LazyMeetingDocumentDialog = lazy( () => import("@/components/meeting-document-dialog") ); const LazyOtpVerificationDialog = lazy(() => import("@/components/otp-dialog")); interface IProps { open: boolean; onOpenChange: (open: boolean) => void; batch: IssuesBatchResponse; onShowResultDialog: (dialog: { open: boolean; content: string }) => void; refetchBatches: RefetchProps; } type DialogType = { open: boolean; isSuccess: boolean; message: React.ReactNode; type: IconType; }; export function getBadgeColorByStatus(status: VoteStatus) { switch (status) { case "CHOBOPHIEU": return "bg-primary-03 text-white"; case "DABOPHIEU": return "bg-primary text-white"; default: return "bg-background-07 text-foreground-09 [&_svg_path]:!fill-[#c5c5c5] [&_svg_path]:!stroke-[#c5c5c5]"; } } // Function to create election schema with dynamic total limit const createElectionSchema = (totalLimit: number) => z .object({ candidates: z.record( z.string(), z.number().min(0, "voteElection.election.error.minVote") ), }) .refine( data => { const total = Object.values(data.candidates).reduce( (sum, votes) => sum + votes, 0 ); return total <= totalLimit; }, { message: "voteElection.election.error.maxVote", path: ["candidates"], } ); type ElectionFormValues = z.infer<ReturnType<typeof createElectionSchema>>; // Form schema for voting const votingSchema = z.object({ batchVote: z .enum([ VOTE_OPTIONS.AGREE, VOTE_OPTIONS.DISAGREE, VOTE_OPTIONS.NOIDEA, VOTE_OPTIONS.NONE, VOTE_OPTIONS.INVALID, ]) .optional(), individualVotes: z .record( z.string(), z.enum([ VOTE_OPTIONS.AGREE, VOTE_OPTIONS.DISAGREE, VOTE_OPTIONS.NOIDEA, VOTE_OPTIONS.NONE, VOTE_OPTIONS.INVALID, ]) ) .refine( record => { const selectedValues = Object.values(record); // Return expected result return selectedValues.every(value => value !== VOTE_OPTIONS.NONE); }, { error: "voteElection.vote.error" } ), }); type VotingFormValues = z.infer<typeof votingSchema>; const VotingElectionDialog = ({ open, onOpenChange, batch, onShowResultDialog, refetchBatches, }: IProps) => { const isElection = batch.batchType !== BATCH_TYPES.BIEUQUYET; const isMobile = useMediaQuery({ maxWidth: 768 }); // Use separate queries for different batch types - only run when dialog is actually open const issuesOptions = issuesByBatchIdQueryOptions(batch.batchId); const issuesQuery = useQuery({ ...issuesOptions, enabled: !isElection && open, queryKey: [...issuesOptions.queryKey, String(open)], // Refetch when dialog opens refetchOnMount: false, }); const candidatesOptions = candidatesByBatchIdQueryOptions(batch.batchId); const candidatesQuery = useQuery({ ...candidatesOptions, enabled: isElection && open, queryKey: [...candidatesOptions.queryKey, String(open)], // Refetch when dialog opens refetchOnMount: false, }); const { i18n } = useTranslation(); const language = i18n.language as Language; const data = isElection ? candidatesQuery.data : issuesQuery.data; const isLoading = isElection ? candidatesQuery.isLoading : issuesQuery.isLoading; const { batchName } = getLocalizedProperties(batch, ["batchName"], language); const contentComponent = !isElection ? ( <VotingContent onOpenChange={onOpenChange} isLoading={isLoading} data={data as ApiResponse<BatchIssuesResponse | null>} onShowResultDialog={onShowResultDialog} invalidateBatchesQuery={refetchBatches} /> ) : ( <ElectionContent onOpenChange={onOpenChange} data={data as ApiResponse<BatchCandidatesResponse | null>} isLoading={isLoading} onShowResultDialog={onShowResultDialog} invalidateBatchesQuery={refetchBatches} /> ); const headerComponent = <VotingElectionHeader batch={batch} />; if (isMobile) { return ( <Drawer open={open} onOpenChange={onOpenChange}> <DrawerContent className="max-h-[90vh]!"> <DrawerHeader className="border-b-2 border-background-02 gap-2"> <div className="flex items-center justify-between"> <DrawerTitle className="text-left text-foreground-12 font-bold responsive-text-3xl"> {batchName} </DrawerTitle> <DrawerClose asChild> <Button variant="ghost" className="size-6 p-0!"> <CancelIcon className="size-6" /> <span className="sr-only">Close</span> </Button> </DrawerClose> </div> {headerComponent} </DrawerHeader> <div className="flex-1 overflow-auto">{contentComponent}</div> </DrawerContent> </Drawer> ); } return ( <CommonDialog noTrans title={batchName} content={contentComponent} open={open} className={cn( "max-w-[360px] xs:max-w-[600px] sm:max-w-[800px] gap-0", "p-0! [&>[data-slot='dialog-header']]:p-4 [&>[data-slot='dialog-header']]:border-b-2 [&>[data-slot='dialog-header']]:border-background-02 [&>[data-slot='dialog-close']]:top-4 [&>[data-slot='dialog-close']]:right-4" )} showCloseBtn={false} onOpenChange={onOpenChange} header={headerComponent} /> ); }; const VotingElectionHeader = ({ batch }: { batch: IssuesBatchResponse }) => { const { t } = useTranslation(["overview"]); return ( <div className="flex items-stretch justify-between"> <Badge variant="outlineDestructive" className="h-[unset]"> <HourglassEagerIcon className="!size-4" /> {t("voteElection.endTime", { endTime: batch.endTime && format(batch.endTime, TIME_FORMAT), })} </Badge> <Button isSmall variant="secondary" className={cn( "max-h-8 p-1.5 responsive-text-sm", getBadgeColorByStatus(batch.votingStatus) )} > {batch.votingStatus == "CHOBOPHIEU" ? t("voteElection.status.waiting") : batch.votingStatus == "DABOPHIEU" ? t("voteElection.status.voted") : t("voteElection.status.closed")} </Button> </div> ); }; const VotingContent = ({ onOpenChange, isLoading, data, onShowResultDialog, invalidateBatchesQuery, }: { onOpenChange: (open: boolean) => void; isLoading: boolean; data: ApiResponse<BatchIssuesResponse | null>; onShowResultDialog: (open: { open: boolean; content: string }) => void; invalidateBatchesQuery: RefetchProps; }) => { const { sendOtpVerify } = useAuthApi(); const { showError } = useError(); const { t, i18n } = useTranslation(["overview"]); const [showOtpDialog, setShowOtpDialog] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false); const language = i18n.language as Language; const form = useForm<VotingFormValues>({ resolver: zodResolver(votingSchema), defaultValues: { batchVote: VOTE_OPTIONS.NONE, individualVotes: {}, }, }); const isVerifyOtp = data?.data?.isRequestOtp; const isDisabled = !data?.data?.isAllowedToVote; const handleOpenDialog = () => { setShowOtpDialog(true); setIsDialogOpen(true); }; const handleCloseDialog = (open: boolean) => { setIsDialogOpen(open); }; // Side effect to initialize form values when data changes useEffect(() => { if (data?.data?.votingIssues) { const initialVotes = data.data.votingIssues.reduce< Record<string, VoteOption> >((acc, cur) => { acc[cur.issueId.toString()] = cur.eVote; return acc; }, {}); // Determine initial batch vote const votes = Object.values(initialVotes); const initialBatchVote = votes.length > 0 && votes.every(vote => vote === votes[0] && vote !== VOTE_OPTIONS.NONE) ? votes[0] : VOTE_OPTIONS.NONE; // Update form values form.setValue("individualVotes", initialVotes); form.setValue("batchVote", initialBatchVote); } }, [data?.data?.votingIssues, form]); const addVoteIssuesMutation = useMutation(voteIssuesOptions()); const [dialog, setDialog] = useState<DialogType>({ open: false, isSuccess: true, message: "", type: "none", }); const handleSubmit = async (params: VotingFormValues) => { // Handle voting form submission console.log("Voting data:", params); if (isVerifyOtp) { sendOtpVerify.mutate(undefined, { onSuccess: function ({ statusCode, ticketId, errorInfo }) { if (isHttpStatusSuccess(statusCode)) { handleOpenDialog(); } else { showError( createErrorWithFallback( { errorInfo, statusCode, }, { details: ticketId || undefined, showErrorCode: true } ) ); } }, }); return; } mutationForm(); }; const onSubmit = form.handleSubmit(handleSubmit); const mutationForm = async (otp?: string) => { if (!data.data) return; const { errorInfo, statusCode, ticketId } = await addVoteIssuesMutation.mutateAsync({ batchId: data.data.batchId, shareQtyConfirm: data.data.shareQtyConfirm, issues: Object.entries(form.getValues().individualVotes).map( ([issueId, vote]) => ({ issueId: Number(issueId), eVote: vote, }) ), otp, }); if (!errorInfo && isHttpStatusSuccess(statusCode)) { await invalidateBatchesQuery(); onShowResultDialog({ open: true, content: t("voteElection.vote.success"), }); handleCloseDialog(false); onOpenChange(false); } else { showError( createErrorWithFallback( { errorInfo, statusCode, }, { details: ticketId || undefined, showErrorCode: true } ) ); } }; // Watch for batchVote changes and update all individual votes const batchVote = useWatch({ control: form.control, name: "batchVote" }); useEffect(() => { // Only update individual votes when batchVote has a valid value (not empty string) if (batchVote !== VOTE_OPTIONS.NONE && batchVote !== undefined) { const newIndividualVotes = data.data?.votingIssues?.reduce( (acc, item) => { acc[item.issueId.toString()] = batchVote; return acc; }, {} as Record<string, VoteOption> ); if (newIndividualVotes) { form.setValue("individualVotes", newIndividualVotes); } } }, [batchVote, form]); // Handle form errors useEffect(() => { if (Object.keys(form.formState.errors).length > 0) { // Handle errors here const errors = Object.values(form.formState.errors); const message = errors.map((err, i) => { const code = err.message?.toString() || err.root?.message; return ( <p key={i}> {errors.length > 1 && "• "} {t(code)} </p> ); }); setDialog({ open: true, message, isSuccess: false, type: "error" }); } }, [form.formState.errors]); return ( <Form {...form}> <div className="px-4 py-3.5"> <div className="flex flex-col gap-3"> {/* Bỏ phiếu hàng loạt */} <div className="grid grid-cols-1 gap-y-2 xs:grid-cols-3 sm:grid-cols-2 px-2.5"> <h2 className="responsive-text text-primary font-semibold text-left"> {t("voteElection.vote.title")} </h2> <FormField disabled={isDisabled} control={form.control} name="batchVote" render={({ field }) => ( <FormItem className="xs:col-span-2 sm:col-span-1"> <FormControl> <RadioGroup value={field.value} onValueChange={field.onChange} className="grid grid-cols-3 gap-5 justify-items-start xs:justify-items-end" > <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.AGREE} id="batch-approve" /> <Label htmlFor="batch-approve" className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.agree")} </Label> </div> <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.DISAGREE} id="batch-disapprove" /> <Label htmlFor="batch-disapprove" className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.disagree")} </Label> </div> <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.NOIDEA} id="batch-abstain" /> <Label htmlFor="batch-abstain" className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.noOption")} </Label> </div> </RadioGroup> </FormControl> </FormItem> )} /> </div> {/* Bỏ phiếu từng biểu quyết */} {!isLoading ? ( data?.data ? ( <ScrollArea className="rounded-default"> {data.data.votingIssues ? ( data.data.votingIssues?.map((item, index) => ( <VotingItem key={item.issueId} item={item} isDisabled={isDisabled} length={data.data?.votingIssues?.length || 0} index={index} form={form} /> )) ) : ( <NoContent /> )} </ScrollArea> ) : ( <ErrorContent errorInfo={data?.errorInfo} className="m-4" /> ) ) : ( <div className="relative min-h-[150px]"> <Skeleton className="h-full rounded-default" /> <LoadingOverlay visible={isLoading} /> </div> )} </div> </div> <VotingElectionFooter onSubmit={onSubmit} isDisabled={isDisabled || isDialogOpen} batchId={data?.data?.batchId} batchName={ language === "vi" ? data?.data?.batchName : data?.data?.batchNameEn } batchType={data?.data?.batchType} /> <CommonDialog className="md:max-w-sm!" open={dialog.open} onOpenChange={(open: boolean) => { if (!open && !dialog.isSuccess) form.clearErrors(); if (!open && dialog.isSuccess && dialog.type !== "none") onOpenChange(false); setDialog({ ...dialog, open }); }} content={dialog.message} iconType={dialog.isSuccess ? "success" : "error"} /> {showOtpDialog && ( <Suspense fallback={<LoadingOverlay visible={true} />}> <LazyOtpVerificationDialog open={isDialogOpen} onOpenChange={handleCloseDialog} onConfirm={mutationForm} /> </Suspense> )} </Form> ); }; const ElectionContent = ({ onOpenChange, data, isLoading, onShowResultDialog, invalidateBatchesQuery, }: { onOpenChange: (open: boolean) => void; data: ApiResponse<BatchCandidatesResponse | null>; isLoading: boolean; onShowResultDialog: (open: { open: boolean; content: string }) => void; invalidateBatchesQuery: RefetchProps; }) => { const { sendOtpVerify } = useAuthApi(); const { showError } = useError(); const { t, i18n } = useTranslation(["overview"]); const [showOtpDialog, setShowOtpDialog] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false); const language = i18n.language as Language; const isVerifyOtp = data?.data?.isRequestOtp; // Safe access to data with loading check const totalLimit = (!isLoading && data?.data?.candidateShareholder?.totalVotes) || 0; const isDisabled = !data?.data?.isAllowedToVote; // Create dynamic schema with the total limit from API const dynamicElectionSchema = createElectionSchema(totalLimit); const form = useForm<ElectionFormValues>({ resolver: zodResolver(dynamicElectionSchema), defaultValues: { candidates: {}, }, }); const handleOpenDialog = () => { setShowOtpDialog(true); setIsDialogOpen(true); }; const handleCloseDialog = (open: boolean) => { setIsDialogOpen(open); }; // Side effect to initialize form values when data changes useEffect(() => { if (!isLoading && data?.data?.votingCandidates) { const initialValues: Record<string, number> = {}; data.data.votingCandidates.forEach(candidate => { initialValues[candidate.candidateId.toString()] = candidate.ballotCounter || 0; }); form.setValue("candidates", initialValues); } }, [data?.data?.votingCandidates, isLoading]); const addVoteCandidatesMutation = useMutation(voteCandidatesOptions()); const [dialog, setDialog] = useState<DialogType>({ open: false, isSuccess: true, message: "", type: "none", }); const isMobileScreen = useMediaQuery({ maxWidth: 480 }); // Watch candidates form values and calculate total voted dynamically using useWatch (form.watch doesn't work) const watchedCandidates = useWatch({ control: form.control, name: "candidates", }) as Record<string, number> | undefined; const totalVoted = Object.values(watchedCandidates || {}).reduce( (sum, v) => sum + (typeof v === "number" ? v : Number(v || 0)), 0 ); const handleSubmit = async (data: ElectionFormValues) => { // Handle election form submission console.log("Election data:", data); if (totalVoted <= 0) { setDialog({ message: t("voteElection.election.error.minVote"), open: true, type: "error", isSuccess: false, }); return; } if (totalVoted < totalLimit) { setDialog({ message: t("voteElection.election.error.remainVote", { remaining: totalLimit - totalVoted, }), open: true, type: "none", isSuccess: true, }); return; } handleVerifyOtpBeforeSubmit(); }; const handleVerifyOtpBeforeSubmit = () => { if (isVerifyOtp) { sendOtpVerify.mutate(undefined, { onSuccess: function ({ statusCode, ticketId, errorInfo }) { if (isHttpStatusSuccess(statusCode)) { handleOpenDialog(); } else { showError( createErrorWithFallback( { errorInfo, statusCode, }, { details: ticketId || undefined, showErrorCode: true } ) ); } }, }); return; } mutationForm(); }; const onSubmit = form.handleSubmit(handleSubmit); const mutationForm = async (otp?: string) => { if (!data.data) return; const formData = form.getValues(); const { errorInfo, statusCode, ticketId } = await addVoteCandidatesMutation.mutateAsync({ batchId: data.data.batchId, batchType: data.data.batchType, candidates: Object.entries(formData.candidates).map( ([candidateId, ballotCounter]) => ({ candidateId: Number(candidateId), ballotCounter, }) ), otp, }); if (!errorInfo && isHttpStatusSuccess(statusCode)) { await invalidateBatchesQuery(); onShowResultDialog({ open: true, content: t("voteElection.election.success"), }); handleCloseDialog(false); onOpenChange(false); } else { showError( createErrorWithFallback( { errorInfo, statusCode, }, { details: ticketId || undefined, showErrorCode: true } ) ); } }; const candidates = (!isLoading && data?.data?.votingCandidates) || []; const [setCandidate, { add, has, remove, reset }] = useSet(new Set<number>()); // Initialize checkboxes for candidates with existing votes (ballotCounter > 0) useUpdateEffect(() => { if (!isLoading && candidates.length > 0) { // Clear existing selections first reset(); // Calculate candidates with votes > 0 const candidatesWithVotes = candidates.filter( candidate => candidate.ballotCounter > 0 ); if (candidatesWithVotes.length > 0) { // Calculate average votes per candidate with votes const avgVotesPerCandidate = Math.floor( totalLimit / candidatesWithVotes.length ); // Only check candidates whose ballotCounter equals the average candidates.forEach(candidate => { if (candidate.ballotCounter === avgVotesPerCandidate) { add(candidate.candidateId); } }); } // Initialize form values with existing ballotCounter values (preserve actual data) const formValues: Record<string, number> = {}; candidates.forEach(candidate => { formValues[candidate.candidateId.toString()] = candidate.ballotCounter || 0; }); form.setValue("candidates", formValues); } }, [candidates, isLoading, totalLimit]); // Handle manual checkbox changes by user interaction const handleCheckboxChange = (candidateId: number, checked: boolean) => { // compute previous size before mutating the set to avoid off-by-one const prevSize = setCandidate.size; if (checked) { add(candidateId); } else { remove(candidateId); } // Calculate new selection set size after the change const newSelectionSize = checked ? prevSize + 1 : Math.max(0, prevSize - 1); if (newSelectionSize > 0) { const avgVotes = Math.floor(totalLimit / newSelectionSize); const formValues: Record<string, number> = {}; candidates.forEach(candidate => { const willBeSelected = candidate.candidateId === candidateId ? checked : has(candidate.candidateId); formValues[candidate.candidateId.toString()] = willBeSelected ? avgVotes : 0; }); form.setValue("candidates", formValues); } else { // Reset all values to 0 when no candidates are selected const formValues: Record<string, number> = {}; candidates.forEach(candidate => { formValues[candidate.candidateId.toString()] = 0; }); form.setValue("candidates", formValues); } }; return ( <Form {...form}> {!isLoading ? ( data?.data ? ( <div className="flex flex-col gap-3.5 px-4 pt-2.5 pb-3"> <div className="flex flex-col gap-2"> <h2 className="responsive-text text-primary font-semibold text-left"> {t("voteElection.election.numElectionInfo")} </h2> <div className="flex max-sm:overflow-x-auto max-sm:overflow-y-hidden gap-3 pb-1"> <div className="max-sm:flex-shrink-0 max-w-40 xs:max-w-52 sm:max-w-64 lg:max-w-none"> <NumberOfShares title={t("shares.shareQtyConfirm")} count={ data?.data.candidateShareholder?.shareQtyConfirm || 0 } /> </div> <div className="max-sm:flex-shrink-0 max-w-40 xs:max-w-52 sm:max-w-64 lg:max-w-none"> <NumberOfShares title={t("shares.candidateCounter")} count={ data?.data.candidateShareholder?.candidateCounter || 0 } /> </div> <div className="max-sm:flex-shrink-0 max-w-40 xs:max-w-52 sm:max-w-64 lg:max-w-none"> <NumberOfShares title={t("shares.totalVotes")} count={data?.data.candidateShareholder?.totalVotes || 0} /> </div> </div> </div> <div className="flex flex-col gap-2 mb-4 rounded-lg"> <h2 className="responsive-text text-primary font-semibold text-left"> {t("voteElection.election.infoCandidate")} </h2> <Table className="text-left"> <TableHeader> <TableRow> <TableHead> {t("voteElection.election.table.candidateName", { tag: isMobileScreen ? <br key="br-1" /> : "", })} </TableHead> <TableHead className="text-center cursor-pointer" onClick={() => { if (setCandidate.size) { // Clear selection and reset form values reset(); form.reset(); } else { // Select all and distribute average votes candidates.forEach(i => add(i.candidateId)); const avgVotes = Math.floor( totalLimit / Math.max(1, candidates.length) ); const values: Record<string, number> = {}; candidates.forEach(c => { values[c.candidateId.toString()] = avgVotes; }); form.setValue("candidates", values); } }} > {t("voteElection.election.table.cumulativeVoting")}{" "} {isMobileScreen && <br key="br-2" />} </TableHead> <TableHead> {t("voteElection.election.table.numberVotes")} </TableHead> </TableRow> </TableHeader> <TableBody> {candidates.map(i => ( <TableRow key={i.candidateId}> <TableCell>{i.fullName}</TableCell> <TableCell className="text-center"> <Checkbox disabled={isDisabled} onCheckedChange={checked => { handleCheckboxChange( i.candidateId, checked === true ); }} checked={has(i.candidateId)} /> </TableCell> <TableCell> <FormField control={form.control} name={`candidates.${i.candidateId}`} render={({ field }) => ( <FormItem> <FormControl> <NumericFormat customInput={Input} placeholder={t( "voteElection.election.table.placeholder" )} className="px-3! py-1.5! min-w-28" disabled={!!setCandidate.size || isDisabled} value={field.value || ""} onValueChange={values => { const { floatValue } = values; field.onChange(floatValue || 0); }} thousandSeparator="," allowNegative={false} decimalScale={0} isAllowed={values => { const { floatValue } = values; return ( floatValue === undefined || floatValue >= 0 ); }} /> </FormControl> </FormItem> )} /> </TableCell> </TableRow> ))} </TableBody> <TableFooter> <TableRow> <TableCell className="text-foreground-05" colSpan={2}> {t("voteElection.election.table.totalVotes")} </TableCell> <TableCell className="text-foreground-05"> {totalVoted} </TableCell> </TableRow> </TableFooter> </Table> </div> </div> ) : ( <ErrorContent errorInfo={data?.errorInfo} className="m-4" /> ) ) : ( <div className="relative min-h-[400px]"> <Skeleton className="h-full rounded-default" /> <LoadingOverlay visible={isLoading} /> </div> )} <VotingElectionFooter onSubmit={onSubmit} isDisabled={isDisabled || isDialogOpen} batchId={data?.data?.batchId} batchName={ language === "vi" ? data?.data?.batchName : data?.data?.batchNameEn } batchType={data?.data?.batchType} /> {showOtpDialog && ( <Suspense fallback={<LoadingOverlay visible={true} />}> <LazyOtpVerificationDialog open={isDialogOpen} onOpenChange={handleCloseDialog} onConfirm={mutationForm} /> </Suspense> )} <CommonDialog open={dialog.open} onOpenChange={(open: boolean) => { if (!open && !dialog.isSuccess) form.clearErrors(); if (!open && dialog.isSuccess && dialog.type !== "none") onOpenChange(false); setDialog({ ...dialog, open }); }} rightButton={ dialog.isSuccess && dialog.type === "none" ? ( <Button className="flex-1" onClick={handleVerifyOtpBeforeSubmit}> {t("voteElection.confirm")} </Button> ) : null } content={dialog.message} iconType={dialog.type} className="md:max-w-sm!" /> </Form> ); }; const VotingItem = ({ item, index, form, length, isDisabled, }: { item: IssuesVoteDTO; index: number; form: UseFormReturn<VotingFormValues>; length: number; isDisabled: boolean; }) => { const { i18n, t } = useTranslation(["overview"]); const language = i18n.language as Language; const { content, description } = getLocalizedProperties( item, ["content", "description"], language ); return ( <div className={cn( "grid grid-cols-1 sm:grid-cols-2 gap-y-2 p-2.5 rounded-none bg-background-06", index < length - 1 && "border-b-2 border-background" )} > <div className="text-foreground-05 text-left flex flex-col gap-1.5"> <h3 className="responsive-text font-semibold">{content}</h3> <p className="responsive-text-sm">{description}</p> </div> <FormField disabled={isDisabled} control={form.control} name={`individualVotes.${item.issueId}`} render={({ field }) => ( <FormItem> <FormControl> <RadioGroup value={field.value} onValueChange={value => { // Clear batch vote when individual vote is changed form.setValue("batchVote", VOTE_OPTIONS.NONE); field.onChange(value); }} className="grid grid-cols-3 gap-5 justify-items-start sm:justify-items-end content-start" > <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.AGREE} id={`approve-${item.issueId}`} /> <Label htmlFor={`approve-${item.issueId}`} className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.agree")} </Label> </div> <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.DISAGREE} id={`disapprove-${item.issueId}`} /> <Label htmlFor={`disapprove-${item.issueId}`} className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.disagree")} </Label> </div> <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.NOIDEA} id={`abstain-${item.issueId}`} /> <Label htmlFor={`abstain-${item.issueId}`} className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.noOption")} </Label> </div> </RadioGroup> </FormControl> </FormItem> )} /> </div> ); }; const VotingElectionFooter = ({ onSubmit, isDisabled, batchId, batchName, batchType, }: { onSubmit?: ( e?: BaseSyntheticEvent<object, any, any> | undefined ) => Promise<void>; isDisabled: boolean; batchId?: string; batchName?: string; batchType?: BatchType; }) => { const form = useFormContext(); const [isLoading, setIsLoading] = useState(false); const { showError } = useError(); const isSubmitting = form.formState.isSubmitting; const { t } = useTranslation(["overview"]); const { i18n } = useTranslation(); const language = i18n.language as Language; const queryClient = useQueryClient(); const [dialog, setDialog] = useState<{ open: boolean; data: MeetingInfoDTO[]; }>({ data: [], open: false, }); const handleFetchGuide = async () => { if (!batchId) return alert("Missing batchId"); const data = await queryClient.fetchQuery( informationQueryOptions(INFO_TYPES.TAILIEU, batchId) ); setDialog({ open: true, data: data.data || [] }); }; const handleDownloadPdf = async (batchId?: string) => { try { if (!batchId) { alert("Missing batchId"); return; } setIsLoading(true); const isVoting = batchType === "BIEUQUYET"; const blobData = await queryClient.fetchQuery( isVoting ? downloadVotingPdf(batchId) : downloadElectionPdf(batchId) ); // The API returns Uint8Array for non-JSON responses (PDFs, etc.) const blob = new Blob([blobData], { type: "application/pdf", }); const blobUrl = URL.createObjectURL(blob); // Create a temporary anchor element to trigger download const link = document.createElement("a"); link.href = blobUrl; const linkName = language === "vi" ? isVoting ? "Giấy biểu quyết" : "Giấy bầu cử" : isVoting ? "Voting form" : "Election form"; link.download = `${linkName} ${batchName}.pdf`; document.body.appendChild(link); link.click(); // Cleanup document.body.removeChild(link); URL.revokeObjectURL(blobUrl); } catch (error) { showError( createErrorWithFallback( error as { errorInfo?: { errorCode?: string; errorMessageVI?: string; errorMessageEN?: string; }; statusCode?: number; }, { showErrorCode: true, } ) ); } finally { setIsLoading(false); } }; return ( <div className="flex flex-col xs:flex-row gap-x-6 gap-y-3 px-4 pb-3"> <div className="flex gap-3 items-center"> <Button type="button" variant="secondary" className="max-h-8 xs:max-h-[unset]" disabled={isLoading} onClick={() => handleDownloadPdf(batchId)} > {!isLoading ? <DownloadIcon /> : <Loader className="animate-spin" />} {t("voteElection.downloadBallot")} </Button> <Button type="button" variant="secondary" className="max-h-8 xs:max-h-[unset]" onClick={handleFetchGuide} > <DocumentIcon className="[&>path]:stroke-primary" /> {t("voteElection.manual")} </Button> </div> {onSubmit && ( <Button type="submit" onClick={onSubmit} className="xs:flex-1" disabled={isDisabled || isSubmitting} > {isSubmitting ? ( <div className="flex items-center justify-center gap-2"> <Loader className="animate-spin flex-1" /> <span>{t("voteElection.confirm") + "..."}</span> </div> ) : ( t("voteElection.confirm") )} </Button> )} <LazyMeetingDocumentDialog open={dialog.open} onOpenChange={open => { if (!open) setDialog({ ...dialog, open }); }} meetingInfos={dialog.data} /> </div> ); }; export default VotingElectionDialog;import CommonDialog, { type IconType } from "@/components/dialog"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { Form, FormControl, FormField, FormItem } from "@/components/ui/form"; import { Drawer, DrawerClose, DrawerContent, DrawerHeader, DrawerTitle, } from "@/components/ui/drawer"; import DocumentIcon from "@/icons/document-icon"; import DownloadIcon from "@/icons/download-icon"; import HourglassEagerIcon from "@/icons/hourglass-eager-icon"; import { cn } from "@/lib/utils"; import { useForm, useFormContext, useWatch, type UseFormReturn, } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import type { BaseSyntheticEvent } from "react"; import { lazy, Suspense, useEffect, useState } from "react"; import NumberOfShares from "./number-of-shares"; import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { useSet, useUpdateEffect } from "react-use"; import { ScrollArea } from "@/components/ui/scroll-area"; import { NumericFormat } from "react-number-format"; import type { BatchCandidatesResponse, BatchIssuesResponse, IssuesBatchResponse, IssuesVoteDTO, MeetingInfoDTO, } from "@/types/information.type"; import { BATCH_TYPES, INFO_TYPES, VOTE_OPTIONS, type BatchType, type VoteOption, type VoteStatus, } from "@/constants/params"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { candidatesByBatchIdQueryOptions, informationQueryOptions, issuesByBatchIdQueryOptions, } from "@/features/information/information.api"; import { getLocalizedProperties, isHttpStatusSuccess } from "@/lib/helper"; import { useTranslation } from "react-i18next"; import type { Language } from "@/constants/language"; import { format } from "date-fns"; import { TIME_FORMAT } from "@/constants/format"; import type { ApiResponse } from "@/types/api.type"; import { Skeleton } from "@/components/ui/skeleton"; import { LoadingOverlay } from "@/components/ui/loading-overlay"; import { useMediaQuery } from "react-responsive"; import CancelIcon from "@/icons/cancel-icon"; import NoContent from "@/components/no-content"; import ErrorContent from "@/components/error-content"; import { downloadElectionPdf, downloadVotingPdf, voteCandidatesOptions, voteIssuesOptions, } from "@/features/meeting/meeting.api"; import { useError } from "@/contexts/error-provider"; import { createErrorWithFallback } from "@/lib/error-utils"; import type { RefetchProps } from "../$meetId/-components/batch-list"; import useAuthApi from "@/features/auth/auth.api"; import { Loader } from "lucide-react"; const LazyMeetingDocumentDialog = lazy( () => import("@/components/meeting-document-dialog") ); const LazyOtpVerificationDialog = lazy(() => import("@/components/otp-dialog")); interface IProps { open: boolean; onOpenChange: (open: boolean) => void; batch: IssuesBatchResponse; onShowResultDialog: (dialog: { open: boolean; content: string }) => void; refetchBatches: RefetchProps; } type DialogType = { open: boolean; isSuccess: boolean; message: React.ReactNode; type: IconType; }; export function getBadgeColorByStatus(status: VoteStatus) { switch (status) { case "CHOBOPHIEU": return "bg-primary-03 text-white"; case "DABOPHIEU": return "bg-primary text-white"; default: return "bg-background-07 text-foreground-09 [&_svg_path]:!fill-[#c5c5c5] [&_svg_path]:!stroke-[#c5c5c5]"; } } // Function to create election schema with dynamic total limit const createElectionSchema = (totalLimit: number) => z .object({ candidates: z.record( z.string(), z.number().min(0, "voteElection.election.error.minVote") ), }) .refine( data => { const total = Object.values(data.candidates).reduce( (sum, votes) => sum + votes, 0 ); return total <= totalLimit; }, { message: "voteElection.election.error.maxVote", path: ["candidates"], } ); type ElectionFormValues = z.infer<ReturnType<typeof createElectionSchema>>; // Form schema for voting const votingSchema = z.object({ batchVote: z .enum([ VOTE_OPTIONS.AGREE, VOTE_OPTIONS.DISAGREE, VOTE_OPTIONS.NOIDEA, VOTE_OPTIONS.NONE, VOTE_OPTIONS.INVALID, ]) .optional(), individualVotes: z .record( z.string(), z.enum([ VOTE_OPTIONS.AGREE, VOTE_OPTIONS.DISAGREE, VOTE_OPTIONS.NOIDEA, VOTE_OPTIONS.NONE, VOTE_OPTIONS.INVALID, ]) ) .refine( record => { const selectedValues = Object.values(record); // Return expected result return selectedValues.every(value => value !== VOTE_OPTIONS.NONE); }, { error: "voteElection.vote.error" } ), }); type VotingFormValues = z.infer<typeof votingSchema>; const VotingElectionDialog = ({ open, onOpenChange, batch, onShowResultDialog, refetchBatches, }: IProps) => { const isElection = batch.batchType !== BATCH_TYPES.BIEUQUYET; const isMobile = useMediaQuery({ maxWidth: 768 }); // Use separate queries for different batch types - only run when dialog is actually open const issuesOptions = issuesByBatchIdQueryOptions(batch.batchId); const issuesQuery = useQuery({ ...issuesOptions, enabled: !isElection && open, queryKey: [...issuesOptions.queryKey, String(open)], // Refetch when dialog opens refetchOnMount: false, }); const candidatesOptions = candidatesByBatchIdQueryOptions(batch.batchId); const candidatesQuery = useQuery({ ...candidatesOptions, enabled: isElection && open, queryKey: [...candidatesOptions.queryKey, String(open)], // Refetch when dialog opens refetchOnMount: false, }); const { i18n } = useTranslation(); const language = i18n.language as Language; const data = isElection ? candidatesQuery.data : issuesQuery.data; const isLoading = isElection ? candidatesQuery.isLoading : issuesQuery.isLoading; const { batchName } = getLocalizedProperties(batch, ["batchName"], language); const contentComponent = !isElection ? ( <VotingContent onOpenChange={onOpenChange} isLoading={isLoading} data={data as ApiResponse<BatchIssuesResponse | null>} onShowResultDialog={onShowResultDialog} invalidateBatchesQuery={refetchBatches} /> ) : ( <ElectionContent onOpenChange={onOpenChange} data={data as ApiResponse<BatchCandidatesResponse | null>} isLoading={isLoading} onShowResultDialog={onShowResultDialog} invalidateBatchesQuery={refetchBatches} /> ); const headerComponent = <VotingElectionHeader batch={batch} />; if (isMobile) { return ( <Drawer open={open} onOpenChange={onOpenChange}> <DrawerContent className="max-h-[90vh]!"> <DrawerHeader className="border-b-2 border-background-02 gap-2"> <div className="flex items-center justify-between"> <DrawerTitle className="text-left text-foreground-12 font-bold responsive-text-3xl"> {batchName} </DrawerTitle> <DrawerClose asChild> <Button variant="ghost" className="size-6 p-0!"> <CancelIcon className="size-6" /> <span className="sr-only">Close</span> </Button> </DrawerClose> </div> {headerComponent} </DrawerHeader> <div className="flex-1 overflow-auto">{contentComponent}</div> </DrawerContent> </Drawer> ); } return ( <CommonDialog noTrans title={batchName} content={contentComponent} open={open} className={cn( "max-w-[360px] xs:max-w-[600px] sm:max-w-[800px] gap-0", "p-0! [&>[data-slot='dialog-header']]:p-4 [&>[data-slot='dialog-header']]:border-b-2 [&>[data-slot='dialog-header']]:border-background-02 [&>[data-slot='dialog-close']]:top-4 [&>[data-slot='dialog-close']]:right-4" )} showCloseBtn={false} onOpenChange={onOpenChange} header={headerComponent} /> ); }; const VotingElectionHeader = ({ batch }: { batch: IssuesBatchResponse }) => { const { t } = useTranslation(["overview"]); return ( <div className="flex items-stretch justify-between"> <Badge variant="outlineDestructive" className="h-[unset]"> <HourglassEagerIcon className="!size-4" /> {t("voteElection.endTime", { endTime: batch.endTime && format(batch.endTime, TIME_FORMAT), })} </Badge> <Button isSmall variant="secondary" className={cn( "max-h-8 p-1.5 responsive-text-sm", getBadgeColorByStatus(batch.votingStatus) )} > {batch.votingStatus == "CHOBOPHIEU" ? t("voteElection.status.waiting") : batch.votingStatus == "DABOPHIEU" ? t("voteElection.status.voted") : t("voteElection.status.closed")} </Button> </div> ); }; const VotingContent = ({ onOpenChange, isLoading, data, onShowResultDialog, invalidateBatchesQuery, }: { onOpenChange: (open: boolean) => void; isLoading: boolean; data: ApiResponse<BatchIssuesResponse | null>; onShowResultDialog: (open: { open: boolean; content: string }) => void; invalidateBatchesQuery: RefetchProps; }) => { const { sendOtpVerify } = useAuthApi(); const { showError } = useError(); const { t, i18n } = useTranslation(["overview"]); const [showOtpDialog, setShowOtpDialog] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false); const language = i18n.language as Language; const form = useForm<VotingFormValues>({ resolver: zodResolver(votingSchema), defaultValues: { batchVote: VOTE_OPTIONS.NONE, individualVotes: {}, }, }); const isVerifyOtp = data?.data?.isRequestOtp; const isDisabled = !data?.data?.isAllowedToVote; const handleOpenDialog = () => { setShowOtpDialog(true); setIsDialogOpen(true); }; const handleCloseDialog = (open: boolean) => { setIsDialogOpen(open); }; // Side effect to initialize form values when data changes useEffect(() => { if (data?.data?.votingIssues) { const initialVotes = data.data.votingIssues.reduce< Record<string, VoteOption> >((acc, cur) => { acc[cur.issueId.toString()] = cur.eVote; return acc; }, {}); // Determine initial batch vote const votes = Object.values(initialVotes); const initialBatchVote = votes.length > 0 && votes.every(vote => vote === votes[0] && vote !== VOTE_OPTIONS.NONE) ? votes[0] : VOTE_OPTIONS.NONE; // Update form values form.setValue("individualVotes", initialVotes); form.setValue("batchVote", initialBatchVote); } }, [data?.data?.votingIssues, form]); const addVoteIssuesMutation = useMutation(voteIssuesOptions()); const [dialog, setDialog] = useState<DialogType>({ open: false, isSuccess: true, message: "", type: "none", }); const handleSubmit = async (params: VotingFormValues) => { // Handle voting form submission console.log("Voting data:", params); if (isVerifyOtp) { sendOtpVerify.mutate(undefined, { onSuccess: function ({ statusCode, ticketId, errorInfo }) { if (isHttpStatusSuccess(statusCode)) { handleOpenDialog(); } else { showError( createErrorWithFallback( { errorInfo, statusCode, }, { details: ticketId || undefined, showErrorCode: true } ) ); } }, }); return; } mutationForm(); }; const onSubmit = form.handleSubmit(handleSubmit); const mutationForm = async (otp?: string) => { if (!data.data) return; const { errorInfo, statusCode, ticketId } = await addVoteIssuesMutation.mutateAsync({ batchId: data.data.batchId, shareQtyConfirm: data.data.shareQtyConfirm, issues: Object.entries(form.getValues().individualVotes).map( ([issueId, vote]) => ({ issueId: Number(issueId), eVote: vote, }) ), otp, }); if (!errorInfo && isHttpStatusSuccess(statusCode)) { await invalidateBatchesQuery(); onShowResultDialog({ open: true, content: t("voteElection.vote.success"), }); handleCloseDialog(false); onOpenChange(false); } else { showError( createErrorWithFallback( { errorInfo, statusCode, }, { details: ticketId || undefined, showErrorCode: true } ) ); } }; // Watch for batchVote changes and update all individual votes const batchVote = useWatch({ control: form.control, name: "batchVote" }); useEffect(() => { // Only update individual votes when batchVote has a valid value (not empty string) if (batchVote !== VOTE_OPTIONS.NONE && batchVote !== undefined) { const newIndividualVotes = data.data?.votingIssues?.reduce( (acc, item) => { acc[item.issueId.toString()] = batchVote; return acc; }, {} as Record<string, VoteOption> ); if (newIndividualVotes) { form.setValue("individualVotes", newIndividualVotes); } } }, [batchVote, form]); // Handle form errors useEffect(() => { if (Object.keys(form.formState.errors).length > 0) { // Handle errors here const errors = Object.values(form.formState.errors); const message = errors.map((err, i) => { const code = err.message?.toString() || err.root?.message; return ( <p key={i}> {errors.length > 1 && "• "} {t(code)} </p> ); }); setDialog({ open: true, message, isSuccess: false, type: "error" }); } }, [form.formState.errors]); return ( <Form {...form}> <div className="px-4 py-3.5"> <div className="flex flex-col gap-3"> {/* Bỏ phiếu hàng loạt */} <div className="grid grid-cols-1 gap-y-2 xs:grid-cols-3 sm:grid-cols-2 px-2.5"> <h2 className="responsive-text text-primary font-semibold text-left"> {t("voteElection.vote.title")} </h2> <FormField disabled={isDisabled} control={form.control} name="batchVote" render={({ field }) => ( <FormItem className="xs:col-span-2 sm:col-span-1"> <FormControl> <RadioGroup value={field.value} onValueChange={field.onChange} className="grid grid-cols-3 gap-5 justify-items-start xs:justify-items-end" > <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.AGREE} id="batch-approve" /> <Label htmlFor="batch-approve" className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.agree")} </Label> </div> <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.DISAGREE} id="batch-disapprove" /> <Label htmlFor="batch-disapprove" className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.disagree")} </Label> </div> <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.NOIDEA} id="batch-abstain" /> <Label htmlFor="batch-abstain" className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.noOption")} </Label> </div> </RadioGroup> </FormControl> </FormItem> )} /> </div> {/* Bỏ phiếu từng biểu quyết */} {!isLoading ? ( data?.data ? ( <ScrollArea className="rounded-default"> {data.data.votingIssues ? ( data.data.votingIssues?.map((item, index) => ( <VotingItem key={item.issueId} item={item} isDisabled={isDisabled} length={data.data?.votingIssues?.length || 0} index={index} form={form} /> )) ) : ( <NoContent /> )} </ScrollArea> ) : ( <ErrorContent errorInfo={data?.errorInfo} className="m-4" /> ) ) : ( <div className="relative min-h-[150px]"> <Skeleton className="h-full rounded-default" /> <LoadingOverlay visible={isLoading} /> </div> )} </div> </div> <VotingElectionFooter onSubmit={onSubmit} isDisabled={isDisabled || isDialogOpen} batchId={data?.data?.batchId} batchName={ language === "vi" ? data?.data?.batchName : data?.data?.batchNameEn } batchType={data?.data?.batchType} /> <CommonDialog className="md:max-w-sm!" open={dialog.open} onOpenChange={(open: boolean) => { if (!open && !dialog.isSuccess) form.clearErrors(); if (!open && dialog.isSuccess && dialog.type !== "none") onOpenChange(false); setDialog({ ...dialog, open }); }} content={dialog.message} iconType={dialog.isSuccess ? "success" : "error"} /> {showOtpDialog && ( <Suspense fallback={<LoadingOverlay visible={true} />}> <LazyOtpVerificationDialog open={isDialogOpen} onOpenChange={handleCloseDialog} onConfirm={mutationForm} /> </Suspense> )} </Form> ); }; const ElectionContent = ({ onOpenChange, data, isLoading, onShowResultDialog, invalidateBatchesQuery, }: { onOpenChange: (open: boolean) => void; data: ApiResponse<BatchCandidatesResponse | null>; isLoading: boolean; onShowResultDialog: (open: { open: boolean; content: string }) => void; invalidateBatchesQuery: RefetchProps; }) => { const { sendOtpVerify } = useAuthApi(); const { showError } = useError(); const { t, i18n } = useTranslation(["overview"]); const [showOtpDialog, setShowOtpDialog] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false); const language = i18n.language as Language; const isVerifyOtp = data?.data?.isRequestOtp; // Safe access to data with loading check const totalLimit = (!isLoading && data?.data?.candidateShareholder?.totalVotes) || 0; const isDisabled = !data?.data?.isAllowedToVote; // Create dynamic schema with the total limit from API const dynamicElectionSchema = createElectionSchema(totalLimit); const form = useForm<ElectionFormValues>({ resolver: zodResolver(dynamicElectionSchema), defaultValues: { candidates: {}, }, }); const handleOpenDialog = () => { setShowOtpDialog(true); setIsDialogOpen(true); }; const handleCloseDialog = (open: boolean) => { setIsDialogOpen(open); }; // Side effect to initialize form values when data changes useEffect(() => { if (!isLoading && data?.data?.votingCandidates) { const initialValues: Record<string, number> = {}; data.data.votingCandidates.forEach(candidate => { initialValues[candidate.candidateId.toString()] = candidate.ballotCounter || 0; }); form.setValue("candidates", initialValues); } }, [data?.data?.votingCandidates, isLoading]); const addVoteCandidatesMutation = useMutation(voteCandidatesOptions()); const [dialog, setDialog] = useState<DialogType>({ open: false, isSuccess: true, message: "", type: "none", }); const isMobileScreen = useMediaQuery({ maxWidth: 480 }); // Watch candidates form values and calculate total voted dynamically using useWatch (form.watch doesn't work) const watchedCandidates = useWatch({ control: form.control, name: "candidates", }) as Record<string, number> | undefined; const totalVoted = Object.values(watchedCandidates || {}).reduce( (sum, v) => sum + (typeof v === "number" ? v : Number(v || 0)), 0 ); const handleSubmit = async (data: ElectionFormValues) => { // Handle election form submission console.log("Election data:", data); if (totalVoted <= 0) { setDialog({ message: t("voteElection.election.error.minVote"), open: true, type: "error", isSuccess: false, }); return; } if (totalVoted < totalLimit) { setDialog({ message: t("voteElection.election.error.remainVote", { remaining: totalLimit - totalVoted, }), open: true, type: "none", isSuccess: true, }); return; } handleVerifyOtpBeforeSubmit(); }; const handleVerifyOtpBeforeSubmit = () => { if (isVerifyOtp) { sendOtpVerify.mutate(undefined, { onSuccess: function ({ statusCode, ticketId, errorInfo }) { if (isHttpStatusSuccess(statusCode)) { handleOpenDialog(); } else { showError( createErrorWithFallback( { errorInfo, statusCode, }, { details: ticketId || undefined, showErrorCode: true } ) ); } }, }); return; } mutationForm(); }; const onSubmit = form.handleSubmit(handleSubmit); const mutationForm = async (otp?: string) => { if (!data.data) return; const formData = form.getValues(); const { errorInfo, statusCode, ticketId } = await addVoteCandidatesMutation.mutateAsync({ batchId: data.data.batchId, batchType: data.data.batchType, candidates: Object.entries(formData.candidates).map( ([candidateId, ballotCounter]) => ({ candidateId: Number(candidateId), ballotCounter, }) ), otp, }); if (!errorInfo && isHttpStatusSuccess(statusCode)) { await invalidateBatchesQuery(); onShowResultDialog({ open: true, content: t("voteElection.election.success"), }); handleCloseDialog(false); onOpenChange(false); } else { showError( createErrorWithFallback( { errorInfo, statusCode, }, { details: ticketId || undefined, showErrorCode: true } ) ); } }; const candidates = (!isLoading && data?.data?.votingCandidates) || []; const [setCandidate, { add, has, remove, reset }] = useSet(new Set<number>()); // Initialize checkboxes for candidates with existing votes (ballotCounter > 0) useUpdateEffect(() => { if (!isLoading && candidates.length > 0) { // Clear existing selections first reset(); // Calculate candidates with votes > 0 const candidatesWithVotes = candidates.filter( candidate => candidate.ballotCounter > 0 ); if (candidatesWithVotes.length > 0) { // Calculate average votes per candidate with votes const avgVotesPerCandidate = Math.floor( totalLimit / candidatesWithVotes.length ); // Only check candidates whose ballotCounter equals the average candidates.forEach(candidate => { if (candidate.ballotCounter === avgVotesPerCandidate) { add(candidate.candidateId); } }); } // Initialize form values with existing ballotCounter values (preserve actual data) const formValues: Record<string, number> = {}; candidates.forEach(candidate => { formValues[candidate.candidateId.toString()] = candidate.ballotCounter || 0; }); form.setValue("candidates", formValues); } }, [candidates, isLoading, totalLimit]); // Handle manual checkbox changes by user interaction const handleCheckboxChange = (candidateId: number, checked: boolean) => { // compute previous size before mutating the set to avoid off-by-one const prevSize = setCandidate.size; if (checked) { add(candidateId); } else { remove(candidateId); } // Calculate new selection set size after the change const newSelectionSize = checked ? prevSize + 1 : Math.max(0, prevSize - 1); if (newSelectionSize > 0) { const avgVotes = Math.floor(totalLimit / newSelectionSize); const formValues: Record<string, number> = {}; candidates.forEach(candidate => { const willBeSelected = candidate.candidateId === candidateId ? checked : has(candidate.candidateId); formValues[candidate.candidateId.toString()] = willBeSelected ? avgVotes : 0; }); form.setValue("candidates", formValues); } else { // Reset all values to 0 when no candidates are selected const formValues: Record<string, number> = {}; candidates.forEach(candidate => { formValues[candidate.candidateId.toString()] = 0; }); form.setValue("candidates", formValues); } }; return ( <Form {...form}> {!isLoading ? ( data?.data ? ( <div className="flex flex-col gap-3.5 px-4 pt-2.5 pb-3"> <div className="flex flex-col gap-2"> <h2 className="responsive-text text-primary font-semibold text-left"> {t("voteElection.election.numElectionInfo")} </h2> <div className="flex max-sm:overflow-x-auto max-sm:overflow-y-hidden gap-3 pb-1"> <div className="max-sm:flex-shrink-0 max-w-40 xs:max-w-52 sm:max-w-64 lg:max-w-none"> <NumberOfShares title={t("shares.shareQtyConfirm")} count={ data?.data.candidateShareholder?.shareQtyConfirm || 0 } /> </div> <div className="max-sm:flex-shrink-0 max-w-40 xs:max-w-52 sm:max-w-64 lg:max-w-none"> <NumberOfShares title={t("shares.candidateCounter")} count={ data?.data.candidateShareholder?.candidateCounter || 0 } /> </div> <div className="max-sm:flex-shrink-0 max-w-40 xs:max-w-52 sm:max-w-64 lg:max-w-none"> <NumberOfShares title={t("shares.totalVotes")} count={data?.data.candidateShareholder?.totalVotes || 0} /> </div> </div> </div> <div className="flex flex-col gap-2 mb-4 rounded-lg"> <h2 className="responsive-text text-primary font-semibold text-left"> {t("voteElection.election.infoCandidate")} </h2> <Table className="text-left"> <TableHeader> <TableRow> <TableHead> {t("voteElection.election.table.candidateName", { tag: isMobileScreen ? <br key="br-1" /> : "", })} </TableHead> <TableHead className="text-center cursor-pointer" onClick={() => { if (setCandidate.size) { // Clear selection and reset form values reset(); form.reset(); } else { // Select all and distribute average votes candidates.forEach(i => add(i.candidateId)); const avgVotes = Math.floor( totalLimit / Math.max(1, candidates.length) ); const values: Record<string, number> = {}; candidates.forEach(c => { values[c.candidateId.toString()] = avgVotes; }); form.setValue("candidates", values); } }} > {t("voteElection.election.table.cumulativeVoting")}{" "} {isMobileScreen && <br key="br-2" />} </TableHead> <TableHead> {t("voteElection.election.table.numberVotes")} </TableHead> </TableRow> </TableHeader> <TableBody> {candidates.map(i => ( <TableRow key={i.candidateId}> <TableCell>{i.fullName}</TableCell> <TableCell className="text-center"> <Checkbox disabled={isDisabled} onCheckedChange={checked => { handleCheckboxChange( i.candidateId, checked === true ); }} checked={has(i.candidateId)} /> </TableCell> <TableCell> <FormField control={form.control} name={`candidates.${i.candidateId}`} render={({ field }) => ( <FormItem> <FormControl> <NumericFormat customInput={Input} placeholder={t( "voteElection.election.table.placeholder" )} className="px-3! py-1.5! min-w-28" disabled={!!setCandidate.size || isDisabled} value={field.value || ""} onValueChange={values => { const { floatValue } = values; field.onChange(floatValue || 0); }} thousandSeparator="," allowNegative={false} decimalScale={0} isAllowed={values => { const { floatValue } = values; return ( floatValue === undefined || floatValue >= 0 ); }} /> </FormControl> </FormItem> )} /> </TableCell> </TableRow> ))} </TableBody> <TableFooter> <TableRow> <TableCell className="text-foreground-05" colSpan={2}> {t("voteElection.election.table.totalVotes")} </TableCell> <TableCell className="text-foreground-05"> {totalVoted} </TableCell> </TableRow> </TableFooter> </Table> </div> </div> ) : ( <ErrorContent errorInfo={data?.errorInfo} className="m-4" /> ) ) : ( <div className="relative min-h-[400px]"> <Skeleton className="h-full rounded-default" /> <LoadingOverlay visible={isLoading} /> </div> )} <VotingElectionFooter onSubmit={onSubmit} isDisabled={isDisabled || isDialogOpen} batchId={data?.data?.batchId} batchName={ language === "vi" ? data?.data?.batchName : data?.data?.batchNameEn } batchType={data?.data?.batchType} /> {showOtpDialog && ( <Suspense fallback={<LoadingOverlay visible={true} />}> <LazyOtpVerificationDialog open={isDialogOpen} onOpenChange={handleCloseDialog} onConfirm={mutationForm} /> </Suspense> )} <CommonDialog open={dialog.open} onOpenChange={(open: boolean) => { if (!open && !dialog.isSuccess) form.clearErrors(); if (!open && dialog.isSuccess && dialog.type !== "none") onOpenChange(false); setDialog({ ...dialog, open }); }} rightButton={ dialog.isSuccess && dialog.type === "none" ? ( <Button className="flex-1" onClick={handleVerifyOtpBeforeSubmit}> {t("voteElection.confirm")} </Button> ) : null } content={dialog.message} iconType={dialog.type} className="md:max-w-sm!" /> </Form> ); }; const VotingItem = ({ item, index, form, length, isDisabled, }: { item: IssuesVoteDTO; index: number; form: UseFormReturn<VotingFormValues>; length: number; isDisabled: boolean; }) => { const { i18n, t } = useTranslation(["overview"]); const language = i18n.language as Language; const { content, description } = getLocalizedProperties( item, ["content", "description"], language ); return ( <div className={cn( "grid grid-cols-1 sm:grid-cols-2 gap-y-2 p-2.5 rounded-none bg-background-06", index < length - 1 && "border-b-2 border-background" )} > <div className="text-foreground-05 text-left flex flex-col gap-1.5"> <h3 className="responsive-text font-semibold">{content}</h3> <p className="responsive-text-sm">{description}</p> </div> <FormField disabled={isDisabled} control={form.control} name={`individualVotes.${item.issueId}`} render={({ field }) => ( <FormItem> <FormControl> <RadioGroup value={field.value} onValueChange={value => { // Clear batch vote when individual vote is changed form.setValue("batchVote", VOTE_OPTIONS.NONE); field.onChange(value); }} className="grid grid-cols-3 gap-5 justify-items-start sm:justify-items-end content-start" > <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.AGREE} id={`approve-${item.issueId}`} /> <Label htmlFor={`approve-${item.issueId}`} className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.agree")} </Label> </div> <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.DISAGREE} id={`disapprove-${item.issueId}`} /> <Label htmlFor={`disapprove-${item.issueId}`} className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.disagree")} </Label> </div> <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.NOIDEA} id={`abstain-${item.issueId}`} /> <Label htmlFor={`abstain-${item.issueId}`} className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.noOption")} </Label> </div> </RadioGroup> </FormControl> </FormItem> )} /> </div> ); }; const VotingElectionFooter = ({ onSubmit, isDisabled, batchId, batchName, batchType, }: { onSubmit?: ( e?: BaseSyntheticEvent<object, any, any> | undefined ) => Promise<void>; isDisabled: boolean; batchId?: string; batchName?: string; batchType?: BatchType; }) => { const form = useFormContext(); const [isLoading, setIsLoading] = useState(false); const { showError } = useError(); const isSubmitting = form.formState.isSubmitting; const { t } = useTranslation(["overview"]); const { i18n } = useTranslation(); const language = i18n.language as Language; const queryClient = useQueryClient(); const [dialog, setDialog] = useState<{ open: boolean; data: MeetingInfoDTO[]; }>({ data: [], open: false, }); const handleFetchGuide = async () => { if (!batchId) return alert("Missing batchId"); const data = await queryClient.fetchQuery( informationQueryOptions(INFO_TYPES.TAILIEU, batchId) ); setDialog({ open: true, data: data.data || [] }); }; const handleDownloadPdf = async (batchId?: string) => { try { if (!batchId) { alert("Missing batchId"); return; } setIsLoading(true); const isVoting = batchType === "BIEUQUYET"; const blobData = await queryClient.fetchQuery( isVoting ? downloadVotingPdf(batchId) : downloadElectionPdf(batchId) ); // The API returns Uint8Array for non-JSON responses (PDFs, etc.) const blob = new Blob([blobData], { type: "application/pdf", }); const blobUrl = URL.createObjectURL(blob); // Create a temporary anchor element to trigger download const link = document.createElement("a"); link.href = blobUrl; const linkName = language === "vi" ? isVoting ? "Giấy biểu quyết" : "Giấy bầu cử" : isVoting ? "Voting form" : "Election form"; link.download = `${linkName} ${batchName}.pdf`; document.body.appendChild(link); link.click(); // Cleanup document.body.removeChild(link); URL.revokeObjectURL(blobUrl); } catch (error) { showError( createErrorWithFallback( error as { errorInfo?: { errorCode?: string; errorMessageVI?: string; errorMessageEN?: string; }; statusCode?: number; }, { showErrorCode: true, } ) ); } finally { setIsLoading(false); } }; return ( <div className="flex flex-col xs:flex-row gap-x-6 gap-y-3 px-4 pb-3"> <div className="flex gap-3 items-center"> <Button type="button" variant="secondary" className="max-h-8 xs:max-h-[unset]" disabled={isLoading} onClick={() => handleDownloadPdf(batchId)} > {!isLoading ? <DownloadIcon /> : <Loader className="animate-spin" />} {t("voteElection.downloadBallot")} </Button> <Button type="button" variant="secondary" className="max-h-8 xs:max-h-[unset]" onClick={handleFetchGuide} > <DocumentIcon className="[&>path]:stroke-primary" /> {t("voteElection.manual")} </Button> </div> {onSubmit && ( <Button type="submit" onClick={onSubmit} className="xs:flex-1" disabled={isDisabled || isSubmitting} > {isSubmitting ? ( <div className="flex items-center justify-center gap-2"> <Loader className="animate-spin flex-1" /> <span>{t("voteElection.confirm") + "..."}</span> </div> ) : ( t("voteElection.confirm") )} </Button> )} <LazyMeetingDocumentDialog open={dialog.open} onOpenChange={open => { if (!open) setDialog({ ...dialog, open }); }} meetingInfos={dialog.data} /> </div> ); }; export default VotingElectionDialog;import CommonDialog, { type IconType } from "@/components/dialog"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { Form, FormControl, FormField, FormItem } from "@/components/ui/form"; import { Drawer, DrawerClose, DrawerContent, DrawerHeader, DrawerTitle, } from "@/components/ui/drawer"; import DocumentIcon from "@/icons/document-icon"; import DownloadIcon from "@/icons/download-icon"; import HourglassEagerIcon from "@/icons/hourglass-eager-icon"; import { cn } from "@/lib/utils"; import { useForm, useFormContext, useWatch, type UseFormReturn, } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import type { BaseSyntheticEvent } from "react"; import { lazy, Suspense, useEffect, useState } from "react"; import NumberOfShares from "./number-of-shares"; import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { useSet, useUpdateEffect } from "react-use"; import { ScrollArea } from "@/components/ui/scroll-area"; import { NumericFormat } from "react-number-format"; import type { BatchCandidatesResponse, BatchIssuesResponse, IssuesBatchResponse, IssuesVoteDTO, MeetingInfoDTO, } from "@/types/information.type"; import { BATCH_TYPES, INFO_TYPES, VOTE_OPTIONS, type BatchType, type VoteOption, type VoteStatus, } from "@/constants/params"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { candidatesByBatchIdQueryOptions, informationQueryOptions, issuesByBatchIdQueryOptions, } from "@/features/information/information.api"; import { getLocalizedProperties, isHttpStatusSuccess } from "@/lib/helper"; import { useTranslation } from "react-i18next"; import type { Language } from "@/constants/language"; import { format } from "date-fns"; import { TIME_FORMAT } from "@/constants/format"; import type { ApiResponse } from "@/types/api.type"; import { Skeleton } from "@/components/ui/skeleton"; import { LoadingOverlay } from "@/components/ui/loading-overlay"; import { useMediaQuery } from "react-responsive"; import CancelIcon from "@/icons/cancel-icon"; import NoContent from "@/components/no-content"; import ErrorContent from "@/components/error-content"; import { downloadElectionPdf, downloadVotingPdf, voteCandidatesOptions, voteIssuesOptions, } from "@/features/meeting/meeting.api"; import { useError } from "@/contexts/error-provider"; import { createErrorWithFallback } from "@/lib/error-utils"; import type { RefetchProps } from "../$meetId/-components/batch-list"; import useAuthApi from "@/features/auth/auth.api"; import { Loader } from "lucide-react"; const LazyMeetingDocumentDialog = lazy( () => import("@/components/meeting-document-dialog") ); const LazyOtpVerificationDialog = lazy(() => import("@/components/otp-dialog")); interface IProps { open: boolean; onOpenChange: (open: boolean) => void; batch: IssuesBatchResponse; onShowResultDialog: (dialog: { open: boolean; content: string }) => void; refetchBatches: RefetchProps; } type DialogType = { open: boolean; isSuccess: boolean; message: React.ReactNode; type: IconType; }; export function getBadgeColorByStatus(status: VoteStatus) { switch (status) { case "CHOBOPHIEU": return "bg-primary-03 text-white"; case "DABOPHIEU": return "bg-primary text-white"; default: return "bg-background-07 text-foreground-09 [&_svg_path]:!fill-[#c5c5c5] [&_svg_path]:!stroke-[#c5c5c5]"; } } // Function to create election schema with dynamic total limit const createElectionSchema = (totalLimit: number) => z .object({ candidates: z.record( z.string(), z.number().min(0, "voteElection.election.error.minVote") ), }) .refine( data => { const total = Object.values(data.candidates).reduce( (sum, votes) => sum + votes, 0 ); return total <= totalLimit; }, { message: "voteElection.election.error.maxVote", path: ["candidates"], } ); type ElectionFormValues = z.infer<ReturnType<typeof createElectionSchema>>; // Form schema for voting const votingSchema = z.object({ batchVote: z .enum([ VOTE_OPTIONS.AGREE, VOTE_OPTIONS.DISAGREE, VOTE_OPTIONS.NOIDEA, VOTE_OPTIONS.NONE, VOTE_OPTIONS.INVALID, ]) .optional(), individualVotes: z .record( z.string(), z.enum([ VOTE_OPTIONS.AGREE, VOTE_OPTIONS.DISAGREE, VOTE_OPTIONS.NOIDEA, VOTE_OPTIONS.NONE, VOTE_OPTIONS.INVALID, ]) ) .refine( record => { const selectedValues = Object.values(record); // Return expected result return selectedValues.every(value => value !== VOTE_OPTIONS.NONE); }, { error: "voteElection.vote.error" } ), }); type VotingFormValues = z.infer<typeof votingSchema>; const VotingElectionDialog = ({ open, onOpenChange, batch, onShowResultDialog, refetchBatches, }: IProps) => { const isElection = batch.batchType !== BATCH_TYPES.BIEUQUYET; const isMobile = useMediaQuery({ maxWidth: 768 }); // Use separate queries for different batch types - only run when dialog is actually open const issuesOptions = issuesByBatchIdQueryOptions(batch.batchId); const issuesQuery = useQuery({ ...issuesOptions, enabled: !isElection && open, queryKey: [...issuesOptions.queryKey, String(open)], // Refetch when dialog opens refetchOnMount: false, }); const candidatesOptions = candidatesByBatchIdQueryOptions(batch.batchId); const candidatesQuery = useQuery({ ...candidatesOptions, enabled: isElection && open, queryKey: [...candidatesOptions.queryKey, String(open)], // Refetch when dialog opens refetchOnMount: false, }); const { i18n } = useTranslation(); const language = i18n.language as Language; const data = isElection ? candidatesQuery.data : issuesQuery.data; const isLoading = isElection ? candidatesQuery.isLoading : issuesQuery.isLoading; const { batchName } = getLocalizedProperties(batch, ["batchName"], language); const contentComponent = !isElection ? ( <VotingContent onOpenChange={onOpenChange} isLoading={isLoading} data={data as ApiResponse<BatchIssuesResponse | null>} onShowResultDialog={onShowResultDialog} invalidateBatchesQuery={refetchBatches} /> ) : ( <ElectionContent onOpenChange={onOpenChange} data={data as ApiResponse<BatchCandidatesResponse | null>} isLoading={isLoading} onShowResultDialog={onShowResultDialog} invalidateBatchesQuery={refetchBatches} /> ); const headerComponent = <VotingElectionHeader batch={batch} />; if (isMobile) { return ( <Drawer open={open} onOpenChange={onOpenChange}> <DrawerContent className="max-h-[90vh]!"> <DrawerHeader className="border-b-2 border-background-02 gap-2"> <div className="flex items-center justify-between"> <DrawerTitle className="text-left text-foreground-12 font-bold responsive-text-3xl"> {batchName} </DrawerTitle> <DrawerClose asChild> <Button variant="ghost" className="size-6 p-0!"> <CancelIcon className="size-6" /> <span className="sr-only">Close</span> </Button> </DrawerClose> </div> {headerComponent} </DrawerHeader> <div className="flex-1 overflow-auto">{contentComponent}</div> </DrawerContent> </Drawer> ); } return ( <CommonDialog noTrans title={batchName} content={contentComponent} open={open} className={cn( "max-w-[360px] xs:max-w-[600px] sm:max-w-[800px] gap-0", "p-0! [&>[data-slot='dialog-header']]:p-4 [&>[data-slot='dialog-header']]:border-b-2 [&>[data-slot='dialog-header']]:border-background-02 [&>[data-slot='dialog-close']]:top-4 [&>[data-slot='dialog-close']]:right-4" )} showCloseBtn={false} onOpenChange={onOpenChange} header={headerComponent} /> ); }; const VotingElectionHeader = ({ batch }: { batch: IssuesBatchResponse }) => { const { t } = useTranslation(["overview"]); return ( <div className="flex items-stretch justify-between"> <Badge variant="outlineDestructive" className="h-[unset]"> <HourglassEagerIcon className="!size-4" /> {t("voteElection.endTime", { endTime: batch.endTime && format(batch.endTime, TIME_FORMAT), })} </Badge> <Button isSmall variant="secondary" className={cn( "max-h-8 p-1.5 responsive-text-sm", getBadgeColorByStatus(batch.votingStatus) )} > {batch.votingStatus == "CHOBOPHIEU" ? t("voteElection.status.waiting") : batch.votingStatus == "DABOPHIEU" ? t("voteElection.status.voted") : t("voteElection.status.closed")} </Button> </div> ); }; const VotingContent = ({ onOpenChange, isLoading, data, onShowResultDialog, invalidateBatchesQuery, }: { onOpenChange: (open: boolean) => void; isLoading: boolean; data: ApiResponse<BatchIssuesResponse | null>; onShowResultDialog: (open: { open: boolean; content: string }) => void; invalidateBatchesQuery: RefetchProps; }) => { const { sendOtpVerify } = useAuthApi(); const { showError } = useError(); const { t, i18n } = useTranslation(["overview"]); const [showOtpDialog, setShowOtpDialog] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false); const language = i18n.language as Language; const form = useForm<VotingFormValues>({ resolver: zodResolver(votingSchema), defaultValues: { batchVote: VOTE_OPTIONS.NONE, individualVotes: {}, }, }); const isVerifyOtp = data?.data?.isRequestOtp; const isDisabled = !data?.data?.isAllowedToVote; const handleOpenDialog = () => { setShowOtpDialog(true); setIsDialogOpen(true); }; const handleCloseDialog = (open: boolean) => { setIsDialogOpen(open); }; // Side effect to initialize form values when data changes useEffect(() => { if (data?.data?.votingIssues) { const initialVotes = data.data.votingIssues.reduce< Record<string, VoteOption> >((acc, cur) => { acc[cur.issueId.toString()] = cur.eVote; return acc; }, {}); // Determine initial batch vote const votes = Object.values(initialVotes); const initialBatchVote = votes.length > 0 && votes.every(vote => vote === votes[0] && vote !== VOTE_OPTIONS.NONE) ? votes[0] : VOTE_OPTIONS.NONE; // Update form values form.setValue("individualVotes", initialVotes); form.setValue("batchVote", initialBatchVote); } }, [data?.data?.votingIssues, form]); const addVoteIssuesMutation = useMutation(voteIssuesOptions()); const [dialog, setDialog] = useState<DialogType>({ open: false, isSuccess: true, message: "", type: "none", }); const handleSubmit = async (params: VotingFormValues) => { // Handle voting form submission console.log("Voting data:", params); if (isVerifyOtp) { sendOtpVerify.mutate(undefined, { onSuccess: function ({ statusCode, ticketId, errorInfo }) { if (isHttpStatusSuccess(statusCode)) { handleOpenDialog(); } else { showError( createErrorWithFallback( { errorInfo, statusCode, }, { details: ticketId || undefined, showErrorCode: true } ) ); } }, }); return; } mutationForm(); }; const onSubmit = form.handleSubmit(handleSubmit); const mutationForm = async (otp?: string) => { if (!data.data) return; const { errorInfo, statusCode, ticketId } = await addVoteIssuesMutation.mutateAsync({ batchId: data.data.batchId, shareQtyConfirm: data.data.shareQtyConfirm, issues: Object.entries(form.getValues().individualVotes).map( ([issueId, vote]) => ({ issueId: Number(issueId), eVote: vote, }) ), otp, }); if (!errorInfo && isHttpStatusSuccess(statusCode)) { await invalidateBatchesQuery(); onShowResultDialog({ open: true, content: t("voteElection.vote.success"), }); handleCloseDialog(false); onOpenChange(false); } else { showError( createErrorWithFallback( { errorInfo, statusCode, }, { details: ticketId || undefined, showErrorCode: true } ) ); } }; // Watch for batchVote changes and update all individual votes const batchVote = useWatch({ control: form.control, name: "batchVote" }); useEffect(() => { // Only update individual votes when batchVote has a valid value (not empty string) if (batchVote !== VOTE_OPTIONS.NONE && batchVote !== undefined) { const newIndividualVotes = data.data?.votingIssues?.reduce( (acc, item) => { acc[item.issueId.toString()] = batchVote; return acc; }, {} as Record<string, VoteOption> ); if (newIndividualVotes) { form.setValue("individualVotes", newIndividualVotes); } } }, [batchVote, form]); // Handle form errors useEffect(() => { if (Object.keys(form.formState.errors).length > 0) { // Handle errors here const errors = Object.values(form.formState.errors); const message = errors.map((err, i) => { const code = err.message?.toString() || err.root?.message; return ( <p key={i}> {errors.length > 1 && "• "} {t(code)} </p> ); }); setDialog({ open: true, message, isSuccess: false, type: "error" }); } }, [form.formState.errors]); return ( <Form {...form}> <div className="px-4 py-3.5"> <div className="flex flex-col gap-3"> {/* Bỏ phiếu hàng loạt */} <div className="grid grid-cols-1 gap-y-2 xs:grid-cols-3 sm:grid-cols-2 px-2.5"> <h2 className="responsive-text text-primary font-semibold text-left"> {t("voteElection.vote.title")} </h2> <FormField disabled={isDisabled} control={form.control} name="batchVote" render={({ field }) => ( <FormItem className="xs:col-span-2 sm:col-span-1"> <FormControl> <RadioGroup value={field.value} onValueChange={field.onChange} className="grid grid-cols-3 gap-5 justify-items-start xs:justify-items-end" > <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.AGREE} id="batch-approve" /> <Label htmlFor="batch-approve" className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.agree")} </Label> </div> <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.DISAGREE} id="batch-disapprove" /> <Label htmlFor="batch-disapprove" className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.disagree")} </Label> </div> <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.NOIDEA} id="batch-abstain" /> <Label htmlFor="batch-abstain" className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.noOption")} </Label> </div> </RadioGroup> </FormControl> </FormItem> )} /> </div> {/* Bỏ phiếu từng biểu quyết */} {!isLoading ? ( data?.data ? ( <ScrollArea className="rounded-default"> {data.data.votingIssues ? ( data.data.votingIssues?.map((item, index) => ( <VotingItem key={item.issueId} item={item} isDisabled={isDisabled} length={data.data?.votingIssues?.length || 0} index={index} form={form} /> )) ) : ( <NoContent /> )} </ScrollArea> ) : ( <ErrorContent errorInfo={data?.errorInfo} className="m-4" /> ) ) : ( <div className="relative min-h-[150px]"> <Skeleton className="h-full rounded-default" /> <LoadingOverlay visible={isLoading} /> </div> )} </div> </div> <VotingElectionFooter onSubmit={onSubmit} isDisabled={isDisabled || isDialogOpen} batchId={data?.data?.batchId} batchName={ language === "vi" ? data?.data?.batchName : data?.data?.batchNameEn } batchType={data?.data?.batchType} /> <CommonDialog className="md:max-w-sm!" open={dialog.open} onOpenChange={(open: boolean) => { if (!open && !dialog.isSuccess) form.clearErrors(); if (!open && dialog.isSuccess && dialog.type !== "none") onOpenChange(false); setDialog({ ...dialog, open }); }} content={dialog.message} iconType={dialog.isSuccess ? "success" : "error"} /> {showOtpDialog && ( <Suspense fallback={<LoadingOverlay visible={true} />}> <LazyOtpVerificationDialog open={isDialogOpen} onOpenChange={handleCloseDialog} onConfirm={mutationForm} /> </Suspense> )} </Form> ); }; const ElectionContent = ({ onOpenChange, data, isLoading, onShowResultDialog, invalidateBatchesQuery, }: { onOpenChange: (open: boolean) => void; data: ApiResponse<BatchCandidatesResponse | null>; isLoading: boolean; onShowResultDialog: (open: { open: boolean; content: string }) => void; invalidateBatchesQuery: RefetchProps; }) => { const { sendOtpVerify } = useAuthApi(); const { showError } = useError(); const { t, i18n } = useTranslation(["overview"]); const [showOtpDialog, setShowOtpDialog] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false); const language = i18n.language as Language; const isVerifyOtp = data?.data?.isRequestOtp; // Safe access to data with loading check const totalLimit = (!isLoading && data?.data?.candidateShareholder?.totalVotes) || 0; const isDisabled = !data?.data?.isAllowedToVote; // Create dynamic schema with the total limit from API const dynamicElectionSchema = createElectionSchema(totalLimit); const form = useForm<ElectionFormValues>({ resolver: zodResolver(dynamicElectionSchema), defaultValues: { candidates: {}, }, }); const handleOpenDialog = () => { setShowOtpDialog(true); setIsDialogOpen(true); }; const handleCloseDialog = (open: boolean) => { setIsDialogOpen(open); }; // Side effect to initialize form values when data changes useEffect(() => { if (!isLoading && data?.data?.votingCandidates) { const initialValues: Record<string, number> = {}; data.data.votingCandidates.forEach(candidate => { initialValues[candidate.candidateId.toString()] = candidate.ballotCounter || 0; }); form.setValue("candidates", initialValues); } }, [data?.data?.votingCandidates, isLoading]); const addVoteCandidatesMutation = useMutation(voteCandidatesOptions()); const [dialog, setDialog] = useState<DialogType>({ open: false, isSuccess: true, message: "", type: "none", }); const isMobileScreen = useMediaQuery({ maxWidth: 480 }); // Watch candidates form values and calculate total voted dynamically using useWatch (form.watch doesn't work) const watchedCandidates = useWatch({ control: form.control, name: "candidates", }) as Record<string, number> | undefined; const totalVoted = Object.values(watchedCandidates || {}).reduce( (sum, v) => sum + (typeof v === "number" ? v : Number(v || 0)), 0 ); const handleSubmit = async (data: ElectionFormValues) => { // Handle election form submission console.log("Election data:", data); if (totalVoted <= 0) { setDialog({ message: t("voteElection.election.error.minVote"), open: true, type: "error", isSuccess: false, }); return; } if (totalVoted < totalLimit) { setDialog({ message: t("voteElection.election.error.remainVote", { remaining: totalLimit - totalVoted, }), open: true, type: "none", isSuccess: true, }); return; } handleVerifyOtpBeforeSubmit(); }; const handleVerifyOtpBeforeSubmit = () => { if (isVerifyOtp) { sendOtpVerify.mutate(undefined, { onSuccess: function ({ statusCode, ticketId, errorInfo }) { if (isHttpStatusSuccess(statusCode)) { handleOpenDialog(); } else { showError( createErrorWithFallback( { errorInfo, statusCode, }, { details: ticketId || undefined, showErrorCode: true } ) ); } }, }); return; } mutationForm(); }; const onSubmit = form.handleSubmit(handleSubmit); const mutationForm = async (otp?: string) => { if (!data.data) return; const formData = form.getValues(); const { errorInfo, statusCode, ticketId } = await addVoteCandidatesMutation.mutateAsync({ batchId: data.data.batchId, batchType: data.data.batchType, candidates: Object.entries(formData.candidates).map( ([candidateId, ballotCounter]) => ({ candidateId: Number(candidateId), ballotCounter, }) ), otp, }); if (!errorInfo && isHttpStatusSuccess(statusCode)) { await invalidateBatchesQuery(); onShowResultDialog({ open: true, content: t("voteElection.election.success"), }); handleCloseDialog(false); onOpenChange(false); } else { showError( createErrorWithFallback( { errorInfo, statusCode, }, { details: ticketId || undefined, showErrorCode: true } ) ); } }; const candidates = (!isLoading && data?.data?.votingCandidates) || []; const [setCandidate, { add, has, remove, reset }] = useSet(new Set<number>()); // Initialize checkboxes for candidates with existing votes (ballotCounter > 0) useUpdateEffect(() => { if (!isLoading && candidates.length > 0) { // Clear existing selections first reset(); // Calculate candidates with votes > 0 const candidatesWithVotes = candidates.filter( candidate => candidate.ballotCounter > 0 ); if (candidatesWithVotes.length > 0) { // Calculate average votes per candidate with votes const avgVotesPerCandidate = Math.floor( totalLimit / candidatesWithVotes.length ); // Only check candidates whose ballotCounter equals the average candidates.forEach(candidate => { if (candidate.ballotCounter === avgVotesPerCandidate) { add(candidate.candidateId); } }); } // Initialize form values with existing ballotCounter values (preserve actual data) const formValues: Record<string, number> = {}; candidates.forEach(candidate => { formValues[candidate.candidateId.toString()] = candidate.ballotCounter || 0; }); form.setValue("candidates", formValues); } }, [candidates, isLoading, totalLimit]); // Handle manual checkbox changes by user interaction const handleCheckboxChange = (candidateId: number, checked: boolean) => { // compute previous size before mutating the set to avoid off-by-one const prevSize = setCandidate.size; if (checked) { add(candidateId); } else { remove(candidateId); } // Calculate new selection set size after the change const newSelectionSize = checked ? prevSize + 1 : Math.max(0, prevSize - 1); if (newSelectionSize > 0) { const avgVotes = Math.floor(totalLimit / newSelectionSize); const formValues: Record<string, number> = {}; candidates.forEach(candidate => { const willBeSelected = candidate.candidateId === candidateId ? checked : has(candidate.candidateId); formValues[candidate.candidateId.toString()] = willBeSelected ? avgVotes : 0; }); form.setValue("candidates", formValues); } else { // Reset all values to 0 when no candidates are selected const formValues: Record<string, number> = {}; candidates.forEach(candidate => { formValues[candidate.candidateId.toString()] = 0; }); form.setValue("candidates", formValues); } }; return ( <Form {...form}> {!isLoading ? ( data?.data ? ( <div className="flex flex-col gap-3.5 px-4 pt-2.5 pb-3"> <div className="flex flex-col gap-2"> <h2 className="responsive-text text-primary font-semibold text-left"> {t("voteElection.election.numElectionInfo")} </h2> <div className="flex max-sm:overflow-x-auto max-sm:overflow-y-hidden gap-3 pb-1"> <div className="max-sm:flex-shrink-0 max-w-40 xs:max-w-52 sm:max-w-64 lg:max-w-none"> <NumberOfShares title={t("shares.shareQtyConfirm")} count={ data?.data.candidateShareholder?.shareQtyConfirm || 0 } /> </div> <div className="max-sm:flex-shrink-0 max-w-40 xs:max-w-52 sm:max-w-64 lg:max-w-none"> <NumberOfShares title={t("shares.candidateCounter")} count={ data?.data.candidateShareholder?.candidateCounter || 0 } /> </div> <div className="max-sm:flex-shrink-0 max-w-40 xs:max-w-52 sm:max-w-64 lg:max-w-none"> <NumberOfShares title={t("shares.totalVotes")} count={data?.data.candidateShareholder?.totalVotes || 0} /> </div> </div> </div> <div className="flex flex-col gap-2 mb-4 rounded-lg"> <h2 className="responsive-text text-primary font-semibold text-left"> {t("voteElection.election.infoCandidate")} </h2> <Table className="text-left"> <TableHeader> <TableRow> <TableHead> {t("voteElection.election.table.candidateName", { tag: isMobileScreen ? <br key="br-1" /> : "", })} </TableHead> <TableHead className="text-center cursor-pointer" onClick={() => { if (setCandidate.size) { // Clear selection and reset form values reset(); form.reset(); } else { // Select all and distribute average votes candidates.forEach(i => add(i.candidateId)); const avgVotes = Math.floor( totalLimit / Math.max(1, candidates.length) ); const values: Record<string, number> = {}; candidates.forEach(c => { values[c.candidateId.toString()] = avgVotes; }); form.setValue("candidates", values); } }} > {t("voteElection.election.table.cumulativeVoting")}{" "} {isMobileScreen && <br key="br-2" />} </TableHead> <TableHead> {t("voteElection.election.table.numberVotes")} </TableHead> </TableRow> </TableHeader> <TableBody> {candidates.map(i => ( <TableRow key={i.candidateId}> <TableCell>{i.fullName}</TableCell> <TableCell className="text-center"> <Checkbox disabled={isDisabled} onCheckedChange={checked => { handleCheckboxChange( i.candidateId, checked === true ); }} checked={has(i.candidateId)} /> </TableCell> <TableCell> <FormField control={form.control} name={`candidates.${i.candidateId}`} render={({ field }) => ( <FormItem> <FormControl> <NumericFormat customInput={Input} placeholder={t( "voteElection.election.table.placeholder" )} className="px-3! py-1.5! min-w-28" disabled={!!setCandidate.size || isDisabled} value={field.value || ""} onValueChange={values => { const { floatValue } = values; field.onChange(floatValue || 0); }} thousandSeparator="," allowNegative={false} decimalScale={0} isAllowed={values => { const { floatValue } = values; return ( floatValue === undefined || floatValue >= 0 ); }} /> </FormControl> </FormItem> )} /> </TableCell> </TableRow> ))} </TableBody> <TableFooter> <TableRow> <TableCell className="text-foreground-05" colSpan={2}> {t("voteElection.election.table.totalVotes")} </TableCell> <TableCell className="text-foreground-05"> {totalVoted} </TableCell> </TableRow> </TableFooter> </Table> </div> </div> ) : ( <ErrorContent errorInfo={data?.errorInfo} className="m-4" /> ) ) : ( <div className="relative min-h-[400px]"> <Skeleton className="h-full rounded-default" /> <LoadingOverlay visible={isLoading} /> </div> )} <VotingElectionFooter onSubmit={onSubmit} isDisabled={isDisabled || isDialogOpen} batchId={data?.data?.batchId} batchName={ language === "vi" ? data?.data?.batchName : data?.data?.batchNameEn } batchType={data?.data?.batchType} /> {showOtpDialog && ( <Suspense fallback={<LoadingOverlay visible={true} />}> <LazyOtpVerificationDialog open={isDialogOpen} onOpenChange={handleCloseDialog} onConfirm={mutationForm} /> </Suspense> )} <CommonDialog open={dialog.open} onOpenChange={(open: boolean) => { if (!open && !dialog.isSuccess) form.clearErrors(); if (!open && dialog.isSuccess && dialog.type !== "none") onOpenChange(false); setDialog({ ...dialog, open }); }} rightButton={ dialog.isSuccess && dialog.type === "none" ? ( <Button className="flex-1" onClick={handleVerifyOtpBeforeSubmit}> {t("voteElection.confirm")} </Button> ) : null } content={dialog.message} iconType={dialog.type} className="md:max-w-sm!" /> </Form> ); }; const VotingItem = ({ item, index, form, length, isDisabled, }: { item: IssuesVoteDTO; index: number; form: UseFormReturn<VotingFormValues>; length: number; isDisabled: boolean; }) => { const { i18n, t } = useTranslation(["overview"]); const language = i18n.language as Language; const { content, description } = getLocalizedProperties( item, ["content", "description"], language ); return ( <div className={cn( "grid grid-cols-1 sm:grid-cols-2 gap-y-2 p-2.5 rounded-none bg-background-06", index < length - 1 && "border-b-2 border-background" )} > <div className="text-foreground-05 text-left flex flex-col gap-1.5"> <h3 className="responsive-text font-semibold">{content}</h3> <p className="responsive-text-sm">{description}</p> </div> <FormField disabled={isDisabled} control={form.control} name={`individualVotes.${item.issueId}`} render={({ field }) => ( <FormItem> <FormControl> <RadioGroup value={field.value} onValueChange={value => { // Clear batch vote when individual vote is changed form.setValue("batchVote", VOTE_OPTIONS.NONE); field.onChange(value); }} className="grid grid-cols-3 gap-5 justify-items-start sm:justify-items-end content-start" > <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.AGREE} id={`approve-${item.issueId}`} /> <Label htmlFor={`approve-${item.issueId}`} className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.agree")} </Label> </div> <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.DISAGREE} id={`disapprove-${item.issueId}`} /> <Label htmlFor={`disapprove-${item.issueId}`} className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.disagree")} </Label> </div> <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.NOIDEA} id={`abstain-${item.issueId}`} /> <Label htmlFor={`abstain-${item.issueId}`} className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.noOption")} </Label> </div> </RadioGroup> </FormControl> </FormItem> )} /> </div> ); }; const VotingElectionFooter = ({ onSubmit, isDisabled, batchId, batchName, batchType, }: { onSubmit?: ( e?: BaseSyntheticEvent<object, any, any> | undefined ) => Promise<void>; isDisabled: boolean; batchId?: string; batchName?: string; batchType?: BatchType; }) => { const form = useFormContext(); const [isLoading, setIsLoading] = useState(false); const { showError } = useError(); const isSubmitting = form.formState.isSubmitting; const { t } = useTranslation(["overview"]); const { i18n } = useTranslation(); const language = i18n.language as Language; const queryClient = useQueryClient(); const [dialog, setDialog] = useState<{ open: boolean; data: MeetingInfoDTO[]; }>({ data: [], open: false, }); const handleFetchGuide = async () => { if (!batchId) return alert("Missing batchId"); const data = await queryClient.fetchQuery( informationQueryOptions(INFO_TYPES.TAILIEU, batchId) ); setDialog({ open: true, data: data.data || [] }); }; const handleDownloadPdf = async (batchId?: string) => { try { if (!batchId) { alert("Missing batchId"); return; } setIsLoading(true); const isVoting = batchType === "BIEUQUYET"; const blobData = await queryClient.fetchQuery( isVoting ? downloadVotingPdf(batchId) : downloadElectionPdf(batchId) ); // The API returns Uint8Array for non-JSON responses (PDFs, etc.) const blob = new Blob([blobData], { type: "application/pdf", }); const blobUrl = URL.createObjectURL(blob); // Create a temporary anchor element to trigger download const link = document.createElement("a"); link.href = blobUrl; const linkName = language === "vi" ? isVoting ? "Giấy biểu quyết" : "Giấy bầu cử" : isVoting ? "Voting form" : "Election form"; link.download = `${linkName} ${batchName}.pdf`; document.body.appendChild(link); link.click(); // Cleanup document.body.removeChild(link); URL.revokeObjectURL(blobUrl); } catch (error) { showError( createErrorWithFallback( error as { errorInfo?: { errorCode?: string; errorMessageVI?: string; errorMessageEN?: string; }; statusCode?: number; }, { showErrorCode: true, } ) ); } finally { setIsLoading(false); } }; return ( <div className="flex flex-col xs:flex-row gap-x-6 gap-y-3 px-4 pb-3"> <div className="flex gap-3 items-center"> <Button type="button" variant="secondary" className="max-h-8 xs:max-h-[unset]" disabled={isLoading} onClick={() => handleDownloadPdf(batchId)} > {!isLoading ? <DownloadIcon /> : <Loader className="animate-spin" />} {t("voteElection.downloadBallot")} </Button> <Button type="button" variant="secondary" className="max-h-8 xs:max-h-[unset]" onClick={handleFetchGuide} > <DocumentIcon className="[&>path]:stroke-primary" /> {t("voteElection.manual")} </Button> </div> {onSubmit && ( <Button type="submit" onClick={onSubmit} className="xs:flex-1" disabled={isDisabled || isSubmitting} > {isSubmitting ? ( <div className="flex items-center justify-center gap-2"> <Loader className="animate-spin flex-1" /> <span>{t("voteElection.confirm") + "..."}</span> </div> ) : ( t("voteElection.confirm") )} </Button> )} <LazyMeetingDocumentDialog open={dialog.open} onOpenChange={open => { if (!open) setDialog({ ...dialog, open }); }} meetingInfos={dialog.data} /> </div> ); }; export default VotingElectionDialog;import CommonDialog, { type IconType } from "@/components/dialog"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { Form, FormControl, FormField, FormItem } from "@/components/ui/form"; import { Drawer, DrawerClose, DrawerContent, DrawerHeader, DrawerTitle, } from "@/components/ui/drawer"; import DocumentIcon from "@/icons/document-icon"; import DownloadIcon from "@/icons/download-icon"; import HourglassEagerIcon from "@/icons/hourglass-eager-icon"; import { cn } from "@/lib/utils"; import { useForm, useFormContext, useWatch, type UseFormReturn, } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import type { BaseSyntheticEvent } from "react"; import { lazy, Suspense, useEffect, useState } from "react"; import NumberOfShares from "./number-of-shares"; import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { useSet, useUpdateEffect } from "react-use"; import { ScrollArea } from "@/components/ui/scroll-area"; import { NumericFormat } from "react-number-format"; import type { BatchCandidatesResponse, BatchIssuesResponse, IssuesBatchResponse, IssuesVoteDTO, MeetingInfoDTO, } from "@/types/information.type"; import { BATCH_TYPES, INFO_TYPES, VOTE_OPTIONS, type BatchType, type VoteOption, type VoteStatus, } from "@/constants/params"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { candidatesByBatchIdQueryOptions, informationQueryOptions, issuesByBatchIdQueryOptions, } from "@/features/information/information.api"; import { getLocalizedProperties, isHttpStatusSuccess } from "@/lib/helper"; import { useTranslation } from "react-i18next"; import type { Language } from "@/constants/language"; import { format } from "date-fns"; import { TIME_FORMAT } from "@/constants/format"; import type { ApiResponse } from "@/types/api.type"; import { Skeleton } from "@/components/ui/skeleton"; import { LoadingOverlay } from "@/components/ui/loading-overlay"; import { useMediaQuery } from "react-responsive"; import CancelIcon from "@/icons/cancel-icon"; import NoContent from "@/components/no-content"; import ErrorContent from "@/components/error-content"; import { downloadElectionPdf, downloadVotingPdf, voteCandidatesOptions, voteIssuesOptions, } from "@/features/meeting/meeting.api"; import { useError } from "@/contexts/error-provider"; import { createErrorWithFallback } from "@/lib/error-utils"; import type { RefetchProps } from "../$meetId/-components/batch-list"; import useAuthApi from "@/features/auth/auth.api"; import { Loader } from "lucide-react"; const LazyMeetingDocumentDialog = lazy( () => import("@/components/meeting-document-dialog") ); const LazyOtpVerificationDialog = lazy(() => import("@/components/otp-dialog")); interface IProps { open: boolean; onOpenChange: (open: boolean) => void; batch: IssuesBatchResponse; onShowResultDialog: (dialog: { open: boolean; content: string }) => void; refetchBatches: RefetchProps; } type DialogType = { open: boolean; isSuccess: boolean; message: React.ReactNode; type: IconType; }; export function getBadgeColorByStatus(status: VoteStatus) { switch (status) { case "CHOBOPHIEU": return "bg-primary-03 text-white"; case "DABOPHIEU": return "bg-primary text-white"; default: return "bg-background-07 text-foreground-09 [&_svg_path]:!fill-[#c5c5c5] [&_svg_path]:!stroke-[#c5c5c5]"; } } // Function to create election schema with dynamic total limit const createElectionSchema = (totalLimit: number) => z .object({ candidates: z.record( z.string(), z.number().min(0, "voteElection.election.error.minVote") ), }) .refine( data => { const total = Object.values(data.candidates).reduce( (sum, votes) => sum + votes, 0 ); return total <= totalLimit; }, { message: "voteElection.election.error.maxVote", path: ["candidates"], } ); type ElectionFormValues = z.infer<ReturnType<typeof createElectionSchema>>; // Form schema for voting const votingSchema = z.object({ batchVote: z .enum([ VOTE_OPTIONS.AGREE, VOTE_OPTIONS.DISAGREE, VOTE_OPTIONS.NOIDEA, VOTE_OPTIONS.NONE, VOTE_OPTIONS.INVALID, ]) .optional(), individualVotes: z .record( z.string(), z.enum([ VOTE_OPTIONS.AGREE, VOTE_OPTIONS.DISAGREE, VOTE_OPTIONS.NOIDEA, VOTE_OPTIONS.NONE, VOTE_OPTIONS.INVALID, ]) ) .refine( record => { const selectedValues = Object.values(record); // Return expected result return selectedValues.every(value => value !== VOTE_OPTIONS.NONE); }, { error: "voteElection.vote.error" } ), }); type VotingFormValues = z.infer<typeof votingSchema>; const VotingElectionDialog = ({ open, onOpenChange, batch, onShowResultDialog, refetchBatches, }: IProps) => { const isElection = batch.batchType !== BATCH_TYPES.BIEUQUYET; const isMobile = useMediaQuery({ maxWidth: 768 }); // Use separate queries for different batch types - only run when dialog is actually open const issuesOptions = issuesByBatchIdQueryOptions(batch.batchId); const issuesQuery = useQuery({ ...issuesOptions, enabled: !isElection && open, queryKey: [...issuesOptions.queryKey, String(open)], // Refetch when dialog opens refetchOnMount: false, }); const candidatesOptions = candidatesByBatchIdQueryOptions(batch.batchId); const candidatesQuery = useQuery({ ...candidatesOptions, enabled: isElection && open, queryKey: [...candidatesOptions.queryKey, String(open)], // Refetch when dialog opens refetchOnMount: false, }); const { i18n } = useTranslation(); const language = i18n.language as Language; const data = isElection ? candidatesQuery.data : issuesQuery.data; const isLoading = isElection ? candidatesQuery.isLoading : issuesQuery.isLoading; const { batchName } = getLocalizedProperties(batch, ["batchName"], language); const contentComponent = !isElection ? ( <VotingContent onOpenChange={onOpenChange} isLoading={isLoading} data={data as ApiResponse<BatchIssuesResponse | null>} onShowResultDialog={onShowResultDialog} invalidateBatchesQuery={refetchBatches} /> ) : ( <ElectionContent onOpenChange={onOpenChange} data={data as ApiResponse<BatchCandidatesResponse | null>} isLoading={isLoading} onShowResultDialog={onShowResultDialog} invalidateBatchesQuery={refetchBatches} /> ); const headerComponent = <VotingElectionHeader batch={batch} />; if (isMobile) { return ( <Drawer open={open} onOpenChange={onOpenChange}> <DrawerContent className="max-h-[90vh]!"> <DrawerHeader className="border-b-2 border-background-02 gap-2"> <div className="flex items-center justify-between"> <DrawerTitle className="text-left text-foreground-12 font-bold responsive-text-3xl"> {batchName} </DrawerTitle> <DrawerClose asChild> <Button variant="ghost" className="size-6 p-0!"> <CancelIcon className="size-6" /> <span className="sr-only">Close</span> </Button> </DrawerClose> </div> {headerComponent} </DrawerHeader> <div className="flex-1 overflow-auto">{contentComponent}</div> </DrawerContent> </Drawer> ); } return ( <CommonDialog noTrans title={batchName} content={contentComponent} open={open} className={cn( "max-w-[360px] xs:max-w-[600px] sm:max-w-[800px] gap-0", "p-0! [&>[data-slot='dialog-header']]:p-4 [&>[data-slot='dialog-header']]:border-b-2 [&>[data-slot='dialog-header']]:border-background-02 [&>[data-slot='dialog-close']]:top-4 [&>[data-slot='dialog-close']]:right-4" )} showCloseBtn={false} onOpenChange={onOpenChange} header={headerComponent} /> ); }; const VotingElectionHeader = ({ batch }: { batch: IssuesBatchResponse }) => { const { t } = useTranslation(["overview"]); return ( <div className="flex items-stretch justify-between"> <Badge variant="outlineDestructive" className="h-[unset]"> <HourglassEagerIcon className="!size-4" /> {t("voteElection.endTime", { endTime: batch.endTime && format(batch.endTime, TIME_FORMAT), })} </Badge> <Button isSmall variant="secondary" className={cn( "max-h-8 p-1.5 responsive-text-sm", getBadgeColorByStatus(batch.votingStatus) )} > {batch.votingStatus == "CHOBOPHIEU" ? t("voteElection.status.waiting") : batch.votingStatus == "DABOPHIEU" ? t("voteElection.status.voted") : t("voteElection.status.closed")} </Button> </div> ); }; const VotingContent = ({ onOpenChange, isLoading, data, onShowResultDialog, invalidateBatchesQuery, }: { onOpenChange: (open: boolean) => void; isLoading: boolean; data: ApiResponse<BatchIssuesResponse | null>; onShowResultDialog: (open: { open: boolean; content: string }) => void; invalidateBatchesQuery: RefetchProps; }) => { const { sendOtpVerify } = useAuthApi(); const { showError } = useError(); const { t, i18n } = useTranslation(["overview"]); const [showOtpDialog, setShowOtpDialog] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false); const language = i18n.language as Language; const form = useForm<VotingFormValues>({ resolver: zodResolver(votingSchema), defaultValues: { batchVote: VOTE_OPTIONS.NONE, individualVotes: {}, }, }); const isVerifyOtp = data?.data?.isRequestOtp; const isDisabled = !data?.data?.isAllowedToVote; const handleOpenDialog = () => { setShowOtpDialog(true); setIsDialogOpen(true); }; const handleCloseDialog = (open: boolean) => { setIsDialogOpen(open); }; // Side effect to initialize form values when data changes useEffect(() => { if (data?.data?.votingIssues) { const initialVotes = data.data.votingIssues.reduce< Record<string, VoteOption> >((acc, cur) => { acc[cur.issueId.toString()] = cur.eVote; return acc; }, {}); // Determine initial batch vote const votes = Object.values(initialVotes); const initialBatchVote = votes.length > 0 && votes.every(vote => vote === votes[0] && vote !== VOTE_OPTIONS.NONE) ? votes[0] : VOTE_OPTIONS.NONE; // Update form values form.setValue("individualVotes", initialVotes); form.setValue("batchVote", initialBatchVote); } }, [data?.data?.votingIssues, form]); const addVoteIssuesMutation = useMutation(voteIssuesOptions()); const [dialog, setDialog] = useState<DialogType>({ open: false, isSuccess: true, message: "", type: "none", }); const handleSubmit = async (params: VotingFormValues) => { // Handle voting form submission console.log("Voting data:", params); if (isVerifyOtp) { sendOtpVerify.mutate(undefined, { onSuccess: function ({ statusCode, ticketId, errorInfo }) { if (isHttpStatusSuccess(statusCode)) { handleOpenDialog(); } else { showError( createErrorWithFallback( { errorInfo, statusCode, }, { details: ticketId || undefined, showErrorCode: true } ) ); } }, }); return; } mutationForm(); }; const onSubmit = form.handleSubmit(handleSubmit); const mutationForm = async (otp?: string) => { if (!data.data) return; const { errorInfo, statusCode, ticketId } = await addVoteIssuesMutation.mutateAsync({ batchId: data.data.batchId, shareQtyConfirm: data.data.shareQtyConfirm, issues: Object.entries(form.getValues().individualVotes).map( ([issueId, vote]) => ({ issueId: Number(issueId), eVote: vote, }) ), otp, }); if (!errorInfo && isHttpStatusSuccess(statusCode)) { await invalidateBatchesQuery(); onShowResultDialog({ open: true, content: t("voteElection.vote.success"), }); handleCloseDialog(false); onOpenChange(false); } else { showError( createErrorWithFallback( { errorInfo, statusCode, }, { details: ticketId || undefined, showErrorCode: true } ) ); } }; // Watch for batchVote changes and update all individual votes const batchVote = useWatch({ control: form.control, name: "batchVote" }); useEffect(() => { // Only update individual votes when batchVote has a valid value (not empty string) if (batchVote !== VOTE_OPTIONS.NONE && batchVote !== undefined) { const newIndividualVotes = data.data?.votingIssues?.reduce( (acc, item) => { acc[item.issueId.toString()] = batchVote; return acc; }, {} as Record<string, VoteOption> ); if (newIndividualVotes) { form.setValue("individualVotes", newIndividualVotes); } } }, [batchVote, form]); // Handle form errors useEffect(() => { if (Object.keys(form.formState.errors).length > 0) { // Handle errors here const errors = Object.values(form.formState.errors); const message = errors.map((err, i) => { const code = err.message?.toString() || err.root?.message; return ( <p key={i}> {errors.length > 1 && "• "} {t(code)} </p> ); }); setDialog({ open: true, message, isSuccess: false, type: "error" }); } }, [form.formState.errors]); return ( <Form {...form}> <div className="px-4 py-3.5"> <div className="flex flex-col gap-3"> {/* Bỏ phiếu hàng loạt */} <div className="grid grid-cols-1 gap-y-2 xs:grid-cols-3 sm:grid-cols-2 px-2.5"> <h2 className="responsive-text text-primary font-semibold text-left"> {t("voteElection.vote.title")} </h2> <FormField disabled={isDisabled} control={form.control} name="batchVote" render={({ field }) => ( <FormItem className="xs:col-span-2 sm:col-span-1"> <FormControl> <RadioGroup value={field.value} onValueChange={field.onChange} className="grid grid-cols-3 gap-5 justify-items-start xs:justify-items-end" > <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.AGREE} id="batch-approve" /> <Label htmlFor="batch-approve" className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.agree")} </Label> </div> <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.DISAGREE} id="batch-disapprove" /> <Label htmlFor="batch-disapprove" className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.disagree")} </Label> </div> <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.NOIDEA} id="batch-abstain" /> <Label htmlFor="batch-abstain" className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.noOption")} </Label> </div> </RadioGroup> </FormControl> </FormItem> )} /> </div> {/* Bỏ phiếu từng biểu quyết */} {!isLoading ? ( data?.data ? ( <ScrollArea className="rounded-default"> {data.data.votingIssues ? ( data.data.votingIssues?.map((item, index) => ( <VotingItem key={item.issueId} item={item} isDisabled={isDisabled} length={data.data?.votingIssues?.length || 0} index={index} form={form} /> )) ) : ( <NoContent /> )} </ScrollArea> ) : ( <ErrorContent errorInfo={data?.errorInfo} className="m-4" /> ) ) : ( <div className="relative min-h-[150px]"> <Skeleton className="h-full rounded-default" /> <LoadingOverlay visible={isLoading} /> </div> )} </div> </div> <VotingElectionFooter onSubmit={onSubmit} isDisabled={isDisabled || isDialogOpen} batchId={data?.data?.batchId} batchName={ language === "vi" ? data?.data?.batchName : data?.data?.batchNameEn } batchType={data?.data?.batchType} /> <CommonDialog className="md:max-w-sm!" open={dialog.open} onOpenChange={(open: boolean) => { if (!open && !dialog.isSuccess) form.clearErrors(); if (!open && dialog.isSuccess && dialog.type !== "none") onOpenChange(false); setDialog({ ...dialog, open }); }} content={dialog.message} iconType={dialog.isSuccess ? "success" : "error"} /> {showOtpDialog && ( <Suspense fallback={<LoadingOverlay visible={true} />}> <LazyOtpVerificationDialog open={isDialogOpen} onOpenChange={handleCloseDialog} onConfirm={mutationForm} /> </Suspense> )} </Form> ); }; const ElectionContent = ({ onOpenChange, data, isLoading, onShowResultDialog, invalidateBatchesQuery, }: { onOpenChange: (open: boolean) => void; data: ApiResponse<BatchCandidatesResponse | null>; isLoading: boolean; onShowResultDialog: (open: { open: boolean; content: string }) => void; invalidateBatchesQuery: RefetchProps; }) => { const { sendOtpVerify } = useAuthApi(); const { showError } = useError(); const { t, i18n } = useTranslation(["overview"]); const [showOtpDialog, setShowOtpDialog] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false); const language = i18n.language as Language; const isVerifyOtp = data?.data?.isRequestOtp; // Safe access to data with loading check const totalLimit = (!isLoading && data?.data?.candidateShareholder?.totalVotes) || 0; const isDisabled = !data?.data?.isAllowedToVote; // Create dynamic schema with the total limit from API const dynamicElectionSchema = createElectionSchema(totalLimit); const form = useForm<ElectionFormValues>({ resolver: zodResolver(dynamicElectionSchema), defaultValues: { candidates: {}, }, }); const handleOpenDialog = () => { setShowOtpDialog(true); setIsDialogOpen(true); }; const handleCloseDialog = (open: boolean) => { setIsDialogOpen(open); }; // Side effect to initialize form values when data changes useEffect(() => { if (!isLoading && data?.data?.votingCandidates) { const initialValues: Record<string, number> = {}; data.data.votingCandidates.forEach(candidate => { initialValues[candidate.candidateId.toString()] = candidate.ballotCounter || 0; }); form.setValue("candidates", initialValues); } }, [data?.data?.votingCandidates, isLoading]); const addVoteCandidatesMutation = useMutation(voteCandidatesOptions()); const [dialog, setDialog] = useState<DialogType>({ open: false, isSuccess: true, message: "", type: "none", }); const isMobileScreen = useMediaQuery({ maxWidth: 480 }); // Watch candidates form values and calculate total voted dynamically using useWatch (form.watch doesn't work) const watchedCandidates = useWatch({ control: form.control, name: "candidates", }) as Record<string, number> | undefined; const totalVoted = Object.values(watchedCandidates || {}).reduce( (sum, v) => sum + (typeof v === "number" ? v : Number(v || 0)), 0 ); const handleSubmit = async (data: ElectionFormValues) => { // Handle election form submission console.log("Election data:", data); if (totalVoted <= 0) { setDialog({ message: t("voteElection.election.error.minVote"), open: true, type: "error", isSuccess: false, }); return; } if (totalVoted < totalLimit) { setDialog({ message: t("voteElection.election.error.remainVote", { remaining: totalLimit - totalVoted, }), open: true, type: "none", isSuccess: true, }); return; } handleVerifyOtpBeforeSubmit(); }; const handleVerifyOtpBeforeSubmit = () => { if (isVerifyOtp) { sendOtpVerify.mutate(undefined, { onSuccess: function ({ statusCode, ticketId, errorInfo }) { if (isHttpStatusSuccess(statusCode)) { handleOpenDialog(); } else { showError( createErrorWithFallback( { errorInfo, statusCode, }, { details: ticketId || undefined, showErrorCode: true } ) ); } }, }); return; } mutationForm(); }; const onSubmit = form.handleSubmit(handleSubmit); const mutationForm = async (otp?: string) => { if (!data.data) return; const formData = form.getValues(); const { errorInfo, statusCode, ticketId } = await addVoteCandidatesMutation.mutateAsync({ batchId: data.data.batchId, batchType: data.data.batchType, candidates: Object.entries(formData.candidates).map( ([candidateId, ballotCounter]) => ({ candidateId: Number(candidateId), ballotCounter, }) ), otp, }); if (!errorInfo && isHttpStatusSuccess(statusCode)) { await invalidateBatchesQuery(); onShowResultDialog({ open: true, content: t("voteElection.election.success"), }); handleCloseDialog(false); onOpenChange(false); } else { showError( createErrorWithFallback( { errorInfo, statusCode, }, { details: ticketId || undefined, showErrorCode: true } ) ); } }; const candidates = (!isLoading && data?.data?.votingCandidates) || []; const [setCandidate, { add, has, remove, reset }] = useSet(new Set<number>()); // Initialize checkboxes for candidates with existing votes (ballotCounter > 0) useUpdateEffect(() => { if (!isLoading && candidates.length > 0) { // Clear existing selections first reset(); // Calculate candidates with votes > 0 const candidatesWithVotes = candidates.filter( candidate => candidate.ballotCounter > 0 ); if (candidatesWithVotes.length > 0) { // Calculate average votes per candidate with votes const avgVotesPerCandidate = Math.floor( totalLimit / candidatesWithVotes.length ); // Only check candidates whose ballotCounter equals the average candidates.forEach(candidate => { if (candidate.ballotCounter === avgVotesPerCandidate) { add(candidate.candidateId); } }); } // Initialize form values with existing ballotCounter values (preserve actual data) const formValues: Record<string, number> = {}; candidates.forEach(candidate => { formValues[candidate.candidateId.toString()] = candidate.ballotCounter || 0; }); form.setValue("candidates", formValues); } }, [candidates, isLoading, totalLimit]); // Handle manual checkbox changes by user interaction const handleCheckboxChange = (candidateId: number, checked: boolean) => { // compute previous size before mutating the set to avoid off-by-one const prevSize = setCandidate.size; if (checked) { add(candidateId); } else { remove(candidateId); } // Calculate new selection set size after the change const newSelectionSize = checked ? prevSize + 1 : Math.max(0, prevSize - 1); if (newSelectionSize > 0) { const avgVotes = Math.floor(totalLimit / newSelectionSize); const formValues: Record<string, number> = {}; candidates.forEach(candidate => { const willBeSelected = candidate.candidateId === candidateId ? checked : has(candidate.candidateId); formValues[candidate.candidateId.toString()] = willBeSelected ? avgVotes : 0; }); form.setValue("candidates", formValues); } else { // Reset all values to 0 when no candidates are selected const formValues: Record<string, number> = {}; candidates.forEach(candidate => { formValues[candidate.candidateId.toString()] = 0; }); form.setValue("candidates", formValues); } }; return ( <Form {...form}> {!isLoading ? ( data?.data ? ( <div className="flex flex-col gap-3.5 px-4 pt-2.5 pb-3"> <div className="flex flex-col gap-2"> <h2 className="responsive-text text-primary font-semibold text-left"> {t("voteElection.election.numElectionInfo")} </h2> <div className="flex max-sm:overflow-x-auto max-sm:overflow-y-hidden gap-3 pb-1"> <div className="max-sm:flex-shrink-0 max-w-40 xs:max-w-52 sm:max-w-64 lg:max-w-none"> <NumberOfShares title={t("shares.shareQtyConfirm")} count={ data?.data.candidateShareholder?.shareQtyConfirm || 0 } /> </div> <div className="max-sm:flex-shrink-0 max-w-40 xs:max-w-52 sm:max-w-64 lg:max-w-none"> <NumberOfShares title={t("shares.candidateCounter")} count={ data?.data.candidateShareholder?.candidateCounter || 0 } /> </div> <div className="max-sm:flex-shrink-0 max-w-40 xs:max-w-52 sm:max-w-64 lg:max-w-none"> <NumberOfShares title={t("shares.totalVotes")} count={data?.data.candidateShareholder?.totalVotes || 0} /> </div> </div> </div> <div className="flex flex-col gap-2 mb-4 rounded-lg"> <h2 className="responsive-text text-primary font-semibold text-left"> {t("voteElection.election.infoCandidate")} </h2> <Table className="text-left"> <TableHeader> <TableRow> <TableHead> {t("voteElection.election.table.candidateName", { tag: isMobileScreen ? <br key="br-1" /> : "", })} </TableHead> <TableHead className="text-center cursor-pointer" onClick={() => { if (setCandidate.size) { // Clear selection and reset form values reset(); form.reset(); } else { // Select all and distribute average votes candidates.forEach(i => add(i.candidateId)); const avgVotes = Math.floor( totalLimit / Math.max(1, candidates.length) ); const values: Record<string, number> = {}; candidates.forEach(c => { values[c.candidateId.toString()] = avgVotes; }); form.setValue("candidates", values); } }} > {t("voteElection.election.table.cumulativeVoting")}{" "} {isMobileScreen && <br key="br-2" />} </TableHead> <TableHead> {t("voteElection.election.table.numberVotes")} </TableHead> </TableRow> </TableHeader> <TableBody> {candidates.map(i => ( <TableRow key={i.candidateId}> <TableCell>{i.fullName}</TableCell> <TableCell className="text-center"> <Checkbox disabled={isDisabled} onCheckedChange={checked => { handleCheckboxChange( i.candidateId, checked === true ); }} checked={has(i.candidateId)} /> </TableCell> <TableCell> <FormField control={form.control} name={`candidates.${i.candidateId}`} render={({ field }) => ( <FormItem> <FormControl> <NumericFormat customInput={Input} placeholder={t( "voteElection.election.table.placeholder" )} className="px-3! py-1.5! min-w-28" disabled={!!setCandidate.size || isDisabled} value={field.value || ""} onValueChange={values => { const { floatValue } = values; field.onChange(floatValue || 0); }} thousandSeparator="," allowNegative={false} decimalScale={0} isAllowed={values => { const { floatValue } = values; return ( floatValue === undefined || floatValue >= 0 ); }} /> </FormControl> </FormItem> )} /> </TableCell> </TableRow> ))} </TableBody> <TableFooter> <TableRow> <TableCell className="text-foreground-05" colSpan={2}> {t("voteElection.election.table.totalVotes")} </TableCell> <TableCell className="text-foreground-05"> {totalVoted} </TableCell> </TableRow> </TableFooter> </Table> </div> </div> ) : ( <ErrorContent errorInfo={data?.errorInfo} className="m-4" /> ) ) : ( <div className="relative min-h-[400px]"> <Skeleton className="h-full rounded-default" /> <LoadingOverlay visible={isLoading} /> </div> )} <VotingElectionFooter onSubmit={onSubmit} isDisabled={isDisabled || isDialogOpen} batchId={data?.data?.batchId} batchName={ language === "vi" ? data?.data?.batchName : data?.data?.batchNameEn } batchType={data?.data?.batchType} /> {showOtpDialog && ( <Suspense fallback={<LoadingOverlay visible={true} />}> <LazyOtpVerificationDialog open={isDialogOpen} onOpenChange={handleCloseDialog} onConfirm={mutationForm} /> </Suspense> )} <CommonDialog open={dialog.open} onOpenChange={(open: boolean) => { if (!open && !dialog.isSuccess) form.clearErrors(); if (!open && dialog.isSuccess && dialog.type !== "none") onOpenChange(false); setDialog({ ...dialog, open }); }} rightButton={ dialog.isSuccess && dialog.type === "none" ? ( <Button className="flex-1" onClick={handleVerifyOtpBeforeSubmit}> {t("voteElection.confirm")} </Button> ) : null } content={dialog.message} iconType={dialog.type} className="md:max-w-sm!" /> </Form> ); }; const VotingItem = ({ item, index, form, length, isDisabled, }: { item: IssuesVoteDTO; index: number; form: UseFormReturn<VotingFormValues>; length: number; isDisabled: boolean; }) => { const { i18n, t } = useTranslation(["overview"]); const language = i18n.language as Language; const { content, description } = getLocalizedProperties( item, ["content", "description"], language ); return ( <div className={cn( "grid grid-cols-1 sm:grid-cols-2 gap-y-2 p-2.5 rounded-none bg-background-06", index < length - 1 && "border-b-2 border-background" )} > <div className="text-foreground-05 text-left flex flex-col gap-1.5"> <h3 className="responsive-text font-semibold">{content}</h3> <p className="responsive-text-sm">{description}</p> </div> <FormField disabled={isDisabled} control={form.control} name={`individualVotes.${item.issueId}`} render={({ field }) => ( <FormItem> <FormControl> <RadioGroup value={field.value} onValueChange={value => { // Clear batch vote when individual vote is changed form.setValue("batchVote", VOTE_OPTIONS.NONE); field.onChange(value); }} className="grid grid-cols-3 gap-5 justify-items-start sm:justify-items-end content-start" > <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.AGREE} id={`approve-${item.issueId}`} /> <Label htmlFor={`approve-${item.issueId}`} className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.agree")} </Label> </div> <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.DISAGREE} id={`disapprove-${item.issueId}`} /> <Label htmlFor={`disapprove-${item.issueId}`} className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.disagree")} </Label> </div> <div className="flex items-center gap-1"> <RadioGroupItem value={VOTE_OPTIONS.NOIDEA} id={`abstain-${item.issueId}`} /> <Label htmlFor={`abstain-${item.issueId}`} className="responsive-text-sm font-normal text-foreground-06" > {t("voteElection.vote.status.noOption")} </Label> </div> </RadioGroup> </FormControl> </FormItem> )} /> </div> ); }; const VotingElectionFooter = ({ onSubmit, isDisabled, batchId, batchName, batchType, }: { onSubmit?: ( e?: BaseSyntheticEvent<object, any, any> | undefined ) => Promise<void>; isDisabled: boolean; batchId?: string; batchName?: string; batchType?: BatchType; }) => { const form = useFormContext(); const [isLoading, setIsLoading] = useState(false); const { showError } = useError(); const isSubmitting = form.formState.isSubmitting; const { t } = useTranslation(["overview"]); const { i18n } = useTranslation(); const language = i18n.language as Language; const queryClient = useQueryClient(); const [dialog, setDialog] = useState<{ open: boolean; data: MeetingInfoDTO[]; }>({ data: [], open: false, }); const handleFetchGuide = async () => { if (!batchId) return alert("Missing batchId"); const data = await queryClient.fetchQuery( informationQueryOptions(INFO_TYPES.TAILIEU, batchId) ); setDialog({ open: true, data: data.data || [] }); }; const handleDownloadPdf = async (batchId?: string) => { try { if (!batchId) { alert("Missing batchId"); return; } setIsLoading(true); const isVoting = batchType === "BIEUQUYET"; const blobData = await queryClient.fetchQuery( isVoting ? downloadVotingPdf(batchId) : downloadElectionPdf(batchId) ); // The API returns Uint8Array for non-JSON responses (PDFs, etc.) const blob = new Blob([blobData], { type: "application/pdf", }); const blobUrl = URL.createObjectURL(blob); // Create a temporary anchor element to trigger download const link = document.createElement("a"); link.href = blobUrl; const linkName = language === "vi" ? isVoting ? "Giấy biểu quyết" : "Giấy bầu cử" : isVoting ? "Voting form" : "Election form"; link.download = `${linkName} ${batchName}.pdf`; document.body.appendChild(link); link.click(); // Cleanup document.body.removeChild(link); URL.revokeObjectURL(blobUrl); } catch (error) { showError( createErrorWithFallback( error as { errorInfo?: { errorCode?: string; errorMessageVI?: string; errorMessageEN?: string; }; statusCode?: number; }, { showErrorCode: true, } ) ); } finally { setIsLoading(false); } }; return ( <div className="flex flex-col xs:flex-row gap-x-6 gap-y-3 px-4 pb-3"> <div className="flex gap-3 items-center"> <Button type="button" variant="secondary" className="max-h-8 xs:max-h-[unset]" disabled={isLoading} onClick={() => handleDownloadPdf(batchId)} > {!isLoading ? <DownloadIcon /> : <Loader className="animate-spin" />} {t("voteElection.downloadBallot")} </Button> <Button type="button" variant="secondary" className="max-h-8 xs:max-h-[unset]" onClick={handleFetchGuide} > <DocumentIcon className="[&>path]:stroke-primary" /> {t("voteElection.manual")} </Button> </div> {onSubmit && ( <Button type="submit" onClick={onSubmit} className="xs:flex-1" disabled={isDisabled || isSubmitting} > {isSubmitting ? ( <div className="flex items-center justify-center gap-2"> <Loader className="animate-spin flex-1" /> <span>{t("voteElection.confirm") + "..."}</span> </div> ) : ( t("voteElection.confirm") )} </Button> )} <LazyMeetingDocumentDialog open={dialog.open} onOpenChange={open => { if (!open) setDialog({ ...dialog, open }); }} meetingInfos={dialog.data} /> </div> ); }; export default VotingElectionDialog;243ialog;ElectionDialog;4