459 lines
14 KiB
TypeScript
459 lines
14 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* 메일 수신자 선택 컴포넌트
|
|
* InteractiveScreenViewer에서 사용하는 래퍼 컴포넌트
|
|
*/
|
|
|
|
import React, { useState, useEffect, useCallback } from "react";
|
|
import { X, Plus, Users, Mail, Check } from "lucide-react";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Label } from "@/components/ui/label";
|
|
import {
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
} from "@/components/ui/popover";
|
|
import {
|
|
Command,
|
|
CommandEmpty,
|
|
CommandGroup,
|
|
CommandInput,
|
|
CommandItem,
|
|
CommandList,
|
|
} from "@/components/ui/command";
|
|
import { cn } from "@/lib/utils";
|
|
import { getUserList } from "@/lib/api/user";
|
|
import type {
|
|
MailRecipientSelectorConfig,
|
|
Recipient,
|
|
InternalUser,
|
|
} from "./types";
|
|
|
|
interface MailRecipientSelectorComponentProps {
|
|
// 컴포넌트 기본 Props
|
|
id?: string;
|
|
componentConfig?: MailRecipientSelectorConfig;
|
|
|
|
// 폼 데이터 연동
|
|
formData?: Record<string, any>;
|
|
onFormDataChange?: (fieldName: string, value: any) => void;
|
|
|
|
// 스타일
|
|
className?: string;
|
|
style?: React.CSSProperties;
|
|
|
|
// 모드
|
|
isPreviewMode?: boolean;
|
|
isInteractive?: boolean;
|
|
isDesignMode?: boolean;
|
|
|
|
// 기타 Props (무시)
|
|
[key: string]: any;
|
|
}
|
|
|
|
export const MailRecipientSelectorComponent: React.FC<
|
|
MailRecipientSelectorComponentProps
|
|
> = ({
|
|
id,
|
|
componentConfig,
|
|
formData = {},
|
|
onFormDataChange,
|
|
className,
|
|
style,
|
|
isPreviewMode = false,
|
|
isInteractive = true,
|
|
isDesignMode = false,
|
|
...rest
|
|
}) => {
|
|
// config 기본값
|
|
const config = componentConfig || {};
|
|
const {
|
|
toFieldName = "mailTo",
|
|
ccFieldName = "mailCc",
|
|
showCc = true,
|
|
showInternalSelector = true,
|
|
showExternalInput = true,
|
|
toLabel = "수신자",
|
|
ccLabel = "참조(CC)",
|
|
maxRecipients,
|
|
maxCcRecipients,
|
|
required = true,
|
|
} = config;
|
|
|
|
// 상태
|
|
const [toRecipients, setToRecipients] = useState<Recipient[]>([]);
|
|
const [ccRecipients, setCcRecipients] = useState<Recipient[]>([]);
|
|
const [externalEmail, setExternalEmail] = useState("");
|
|
const [externalCcEmail, setExternalCcEmail] = useState("");
|
|
const [internalUsers, setInternalUsers] = useState<InternalUser[]>([]);
|
|
const [isLoadingUsers, setIsLoadingUsers] = useState(false);
|
|
const [toPopoverOpen, setToPopoverOpen] = useState(false);
|
|
const [ccPopoverOpen, setCcPopoverOpen] = useState(false);
|
|
|
|
// 내부 사용자 목록 로드
|
|
const loadInternalUsers = useCallback(async () => {
|
|
if (!showInternalSelector || isDesignMode) return;
|
|
|
|
setIsLoadingUsers(true);
|
|
try {
|
|
const response = await getUserList({ status: "active", limit: 1000 });
|
|
if (response.success && response.data) {
|
|
setInternalUsers(response.data);
|
|
}
|
|
} catch (error) {
|
|
console.error("사용자 목록 로드 실패:", error);
|
|
} finally {
|
|
setIsLoadingUsers(false);
|
|
}
|
|
}, [showInternalSelector, isDesignMode]);
|
|
|
|
// 컴포넌트 마운트 시 사용자 목록 로드
|
|
useEffect(() => {
|
|
loadInternalUsers();
|
|
}, [loadInternalUsers]);
|
|
|
|
// formData에서 초기값 로드
|
|
useEffect(() => {
|
|
if (formData[toFieldName]) {
|
|
const emails = formData[toFieldName].split(",").filter(Boolean);
|
|
const recipients: Recipient[] = emails.map((email: string) => ({
|
|
id: `external-${email.trim()}`,
|
|
email: email.trim(),
|
|
type: "external" as const,
|
|
}));
|
|
setToRecipients(recipients);
|
|
}
|
|
|
|
if (formData[ccFieldName]) {
|
|
const emails = formData[ccFieldName].split(",").filter(Boolean);
|
|
const recipients: Recipient[] = emails.map((email: string) => ({
|
|
id: `external-${email.trim()}`,
|
|
email: email.trim(),
|
|
type: "external" as const,
|
|
}));
|
|
setCcRecipients(recipients);
|
|
}
|
|
}, []);
|
|
|
|
// 수신자 변경 시 formData 업데이트
|
|
const updateFormData = useCallback(
|
|
(recipients: Recipient[], fieldName: string) => {
|
|
const emailString = recipients.map((r) => r.email).join(",");
|
|
onFormDataChange?.(fieldName, emailString);
|
|
},
|
|
[onFormDataChange]
|
|
);
|
|
|
|
// 수신자 추가 (내부 사용자)
|
|
const addInternalRecipient = useCallback(
|
|
(user: InternalUser, type: "to" | "cc") => {
|
|
const email = user.email || `${user.userId}@company.com`;
|
|
const newRecipient: Recipient = {
|
|
id: `internal-${user.userId}`,
|
|
email,
|
|
name: user.userName,
|
|
type: "internal",
|
|
userId: user.userId,
|
|
};
|
|
|
|
if (type === "to") {
|
|
// 중복 체크
|
|
if (toRecipients.some((r) => r.email === email)) return;
|
|
// 최대 수신자 수 체크
|
|
if (maxRecipients && toRecipients.length >= maxRecipients) return;
|
|
|
|
const updated = [...toRecipients, newRecipient];
|
|
setToRecipients(updated);
|
|
updateFormData(updated, toFieldName);
|
|
setToPopoverOpen(false);
|
|
} else {
|
|
if (ccRecipients.some((r) => r.email === email)) return;
|
|
if (maxCcRecipients && ccRecipients.length >= maxCcRecipients) return;
|
|
|
|
const updated = [...ccRecipients, newRecipient];
|
|
setCcRecipients(updated);
|
|
updateFormData(updated, ccFieldName);
|
|
setCcPopoverOpen(false);
|
|
}
|
|
},
|
|
[
|
|
toRecipients,
|
|
ccRecipients,
|
|
maxRecipients,
|
|
maxCcRecipients,
|
|
toFieldName,
|
|
ccFieldName,
|
|
updateFormData,
|
|
]
|
|
);
|
|
|
|
// 외부 이메일 추가
|
|
const addExternalEmail = useCallback(
|
|
(email: string, type: "to" | "cc") => {
|
|
// 이메일 형식 검증
|
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
if (!emailRegex.test(email)) return;
|
|
|
|
const newRecipient: Recipient = {
|
|
id: `external-${email}-${type}`,
|
|
email,
|
|
type: "external",
|
|
};
|
|
|
|
if (type === "to") {
|
|
// 해당 필드 내에서만 중복 체크
|
|
if (toRecipients.some((r) => r.email === email)) return;
|
|
if (maxRecipients && toRecipients.length >= maxRecipients) return;
|
|
|
|
const updated = [...toRecipients, newRecipient];
|
|
setToRecipients(updated);
|
|
updateFormData(updated, toFieldName);
|
|
setExternalEmail("");
|
|
} else {
|
|
// 해당 필드 내에서만 중복 체크
|
|
if (ccRecipients.some((r) => r.email === email)) return;
|
|
if (maxCcRecipients && ccRecipients.length >= maxCcRecipients) return;
|
|
|
|
const updated = [...ccRecipients, newRecipient];
|
|
setCcRecipients(updated);
|
|
updateFormData(updated, ccFieldName);
|
|
setExternalCcEmail("");
|
|
}
|
|
},
|
|
[
|
|
toRecipients,
|
|
ccRecipients,
|
|
maxRecipients,
|
|
maxCcRecipients,
|
|
toFieldName,
|
|
ccFieldName,
|
|
updateFormData,
|
|
]
|
|
);
|
|
|
|
// 수신자 제거
|
|
const removeRecipient = useCallback(
|
|
(recipientId: string, type: "to" | "cc") => {
|
|
if (type === "to") {
|
|
const updated = toRecipients.filter((r) => r.id !== recipientId);
|
|
setToRecipients(updated);
|
|
updateFormData(updated, toFieldName);
|
|
} else {
|
|
const updated = ccRecipients.filter((r) => r.id !== recipientId);
|
|
setCcRecipients(updated);
|
|
updateFormData(updated, ccFieldName);
|
|
}
|
|
},
|
|
[toRecipients, ccRecipients, toFieldName, ccFieldName, updateFormData]
|
|
);
|
|
|
|
// 이미 선택된 사용자인지 확인 (해당 필드 내에서만 중복 체크)
|
|
const isUserSelected = useCallback(
|
|
(user: InternalUser, type: "to" | "cc") => {
|
|
const recipients = type === "to" ? toRecipients : ccRecipients;
|
|
const userEmail = user.email || `${user.userId}@company.com`;
|
|
return recipients.some((r) => r.email === userEmail);
|
|
},
|
|
[toRecipients, ccRecipients]
|
|
);
|
|
|
|
// 수신자 태그 렌더링
|
|
const renderRecipientTags = (recipients: Recipient[], type: "to" | "cc") => (
|
|
<div className="flex flex-wrap gap-1">
|
|
{recipients.map((recipient) => (
|
|
<Badge
|
|
key={recipient.id}
|
|
variant={recipient.type === "internal" ? "default" : "secondary"}
|
|
className="flex items-center gap-1 pr-1"
|
|
>
|
|
{recipient.type === "internal" ? (
|
|
<Users className="h-3 w-3" />
|
|
) : (
|
|
<Mail className="h-3 w-3" />
|
|
)}
|
|
<span className="max-w-[150px] truncate">
|
|
{recipient.name || recipient.email}
|
|
</span>
|
|
{isInteractive && !isDesignMode && (
|
|
<button
|
|
type="button"
|
|
onClick={() => removeRecipient(recipient.id, type)}
|
|
className="ml-1 rounded-full p-0.5 hover:bg-white/20"
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</button>
|
|
)}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
);
|
|
|
|
// 내부 사용자 선택 팝오버
|
|
const renderInternalSelector = (type: "to" | "cc") => {
|
|
const isOpen = type === "to" ? toPopoverOpen : ccPopoverOpen;
|
|
const setIsOpen = type === "to" ? setToPopoverOpen : setCcPopoverOpen;
|
|
|
|
return (
|
|
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-8"
|
|
disabled={!isInteractive || isLoadingUsers || isDesignMode}
|
|
>
|
|
<Users className="mr-1 h-4 w-4" />
|
|
내부 인원
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-[300px] p-0" align="start">
|
|
<Command>
|
|
<CommandInput placeholder="이름 또는 이메일로 검색..." />
|
|
<CommandList>
|
|
<CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
|
|
<CommandGroup>
|
|
{internalUsers.map((user, index) => {
|
|
const userEmail = user.email || `${user.userId}@company.com`;
|
|
const selected = isUserSelected(user, type);
|
|
const uniqueKey = `${user.userId}-${index}`;
|
|
return (
|
|
<CommandItem
|
|
key={uniqueKey}
|
|
value={`${user.userId}-${user.userName}-${userEmail}`}
|
|
onSelect={() => {
|
|
if (!selected) {
|
|
addInternalRecipient(user, type);
|
|
}
|
|
}}
|
|
className={cn(
|
|
"cursor-pointer",
|
|
selected && "opacity-50 cursor-not-allowed"
|
|
)}
|
|
>
|
|
<div className="flex flex-1 items-center justify-between">
|
|
<div className="flex flex-col">
|
|
<span className="font-medium">{user.userName}</span>
|
|
<span className="text-xs text-gray-500">
|
|
{userEmail}
|
|
{user.deptName && ` | ${user.deptName}`}
|
|
</span>
|
|
</div>
|
|
{selected && <Check className="h-4 w-4 text-green-500" />}
|
|
</div>
|
|
</CommandItem>
|
|
);
|
|
})}
|
|
</CommandGroup>
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
);
|
|
};
|
|
|
|
// 외부 이메일 입력
|
|
const renderExternalInput = (type: "to" | "cc") => {
|
|
const value = type === "to" ? externalEmail : externalCcEmail;
|
|
const setValue = type === "to" ? setExternalEmail : setExternalCcEmail;
|
|
|
|
return (
|
|
<div className="flex gap-1">
|
|
<Input
|
|
type="email"
|
|
placeholder="외부 이메일 입력"
|
|
value={value}
|
|
onChange={(e) => setValue(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") {
|
|
e.preventDefault();
|
|
addExternalEmail(value, type);
|
|
}
|
|
}}
|
|
className="h-8 flex-1 text-sm"
|
|
disabled={!isInteractive || isDesignMode}
|
|
/>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-8"
|
|
onClick={() => addExternalEmail(value, type)}
|
|
disabled={!isInteractive || !value || isDesignMode}
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// 디자인 모드 또는 프리뷰 모드
|
|
if (isDesignMode || isPreviewMode) {
|
|
return (
|
|
<div className={cn("space-y-3 rounded-md border p-3 bg-white", className)} style={style}>
|
|
<div className="text-sm font-medium text-gray-500">메일 수신자 선택</div>
|
|
<div className="space-y-2">
|
|
<div className="flex items-center gap-2 text-sm text-gray-400">
|
|
<Users className="h-4 w-4" />
|
|
<span>내부 인원 선택</span>
|
|
</div>
|
|
<div className="flex items-center gap-2 text-sm text-gray-400">
|
|
<Mail className="h-4 w-4" />
|
|
<span>외부 이메일 입력</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className={cn("space-y-4", className)} style={style}>
|
|
{/* 수신자 (To) */}
|
|
<div className="space-y-2">
|
|
<Label className="text-sm font-medium">
|
|
{toLabel}
|
|
{required && <span className="ml-1 text-red-500">*</span>}
|
|
</Label>
|
|
|
|
{/* 선택된 수신자 태그 */}
|
|
{toRecipients.length > 0 && (
|
|
<div className="rounded-md border bg-gray-50 p-2">
|
|
{renderRecipientTags(toRecipients, "to")}
|
|
</div>
|
|
)}
|
|
|
|
{/* 추가 버튼들 */}
|
|
<div className="flex flex-wrap gap-2">
|
|
{showInternalSelector && renderInternalSelector("to")}
|
|
{showExternalInput && renderExternalInput("to")}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 참조 (CC) */}
|
|
{showCc && (
|
|
<div className="space-y-2">
|
|
<Label className="text-sm font-medium">{ccLabel}</Label>
|
|
|
|
{/* 선택된 참조 수신자 태그 */}
|
|
{ccRecipients.length > 0 && (
|
|
<div className="rounded-md border bg-gray-50 p-2">
|
|
{renderRecipientTags(ccRecipients, "cc")}
|
|
</div>
|
|
)}
|
|
|
|
{/* 추가 버튼들 */}
|
|
<div className="flex flex-wrap gap-2">
|
|
{showInternalSelector && renderInternalSelector("cc")}
|
|
{showExternalInput && renderExternalInput("cc")}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default MailRecipientSelectorComponent;
|