Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node

This commit is contained in:
kjs
2026-03-12 14:23:34 +09:00
99 changed files with 14205 additions and 1442 deletions
+13 -2
View File
@@ -6,8 +6,17 @@ import { LoginForm } from "@/components/auth/LoginForm";
import { LoginFooter } from "@/components/auth/LoginFooter";
export default function LoginPage() {
const { formData, isLoading, error, showPassword, handleInputChange, handleLogin, togglePasswordVisibility } =
useLogin();
const {
formData,
isLoading,
error,
showPassword,
isPopMode,
handleInputChange,
handleLogin,
togglePasswordVisibility,
togglePopMode,
} = useLogin();
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-muted/40 p-4">
@@ -19,9 +28,11 @@ export default function LoginPage() {
isLoading={isLoading}
error={error}
showPassword={showPassword}
isPopMode={isPopMode}
onInputChange={handleInputChange}
onSubmit={handleLogin}
onTogglePassword={togglePasswordVisibility}
onTogglePop={togglePopMode}
/>
<LoginFooter />
@@ -3,7 +3,7 @@
import React, { useEffect, useState } from "react";
import { useParams, useSearchParams } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Loader2, ArrowLeft, Smartphone, Tablet, RotateCcw, RotateCw } from "lucide-react";
import { Loader2, ArrowLeft, Smartphone, Tablet, RotateCcw, RotateCw, LayoutGrid, Monitor } from "lucide-react";
import { screenApi } from "@/lib/api/screen";
import { ScreenDefinition } from "@/types/screen";
import { useRouter } from "next/navigation";
@@ -285,14 +285,23 @@ function PopScreenViewPage() {
</div>
)}
{/* 일반 모드 네비게이션 바 */}
{!isPreviewMode && (
<div className="sticky top-0 z-50 flex h-10 items-center justify-between border-b bg-white/80 px-3 backdrop-blur">
<Button variant="ghost" size="sm" onClick={() => router.push("/pop")} className="gap-1 text-xs">
<LayoutGrid className="h-3.5 w-3.5" />
POP
</Button>
<span className="text-xs text-gray-500">{screen.screenName}</span>
<Button variant="ghost" size="sm" onClick={() => router.push("/")} className="gap-1 text-xs">
<Monitor className="h-3.5 w-3.5" />
PC
</Button>
</div>
)}
{/* POP 화면 컨텐츠 */}
<div className={`flex-1 flex flex-col overflow-auto ${isPreviewMode ? "py-4 items-center" : "bg-white"}`}>
{/* 현재 모드 표시 (일반 모드) */}
{!isPreviewMode && (
<div className="absolute top-2 right-2 z-10 bg-black/50 text-white text-xs px-2 py-1 rounded">
{currentModeKey.replace("_", " ")}
</div>
)}
<div
className={`bg-white transition-all duration-300 ${isPreviewMode ? "shadow-2xl rounded-3xl overflow-auto border-8 border-foreground" : "w-full min-h-full"}`}
+8
View File
@@ -458,6 +458,14 @@ select {
border-color: hsl(var(--destructive)) !important;
}
/* 채번 세그먼트 포커스 스타일 (shadcn Input과 동일한 3단 구조) */
.numbering-segment:focus-within {
box-shadow: 0 0 0 3px hsl(var(--ring) / 0.5);
outline: 2px solid hsl(var(--ring));
outline-offset: 2px;
}
/* 필수 입력 경고 문구 (입력 필드 아래, 레이아웃 영향 없음) */
.validation-error-msg-wrapper {
height: 0;
+245 -22
View File
@@ -82,12 +82,19 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
});
// 화면 할당 관련 상태
const [urlType, setUrlType] = useState<"direct" | "screen" | "dashboard">("screen"); // URL 직접 입력 or 화면 할당 or 대시보드 할당 (기본값: 화면 할당)
const [urlType, setUrlType] = useState<"direct" | "screen" | "dashboard" | "pop">("screen");
const [selectedScreen, setSelectedScreen] = useState<ScreenDefinition | null>(null);
const [screens, setScreens] = useState<ScreenDefinition[]>([]);
const [screenSearchText, setScreenSearchText] = useState("");
const [isScreenDropdownOpen, setIsScreenDropdownOpen] = useState(false);
// POP 화면 할당 관련 상태
const [selectedPopScreen, setSelectedPopScreen] = useState<ScreenDefinition | null>(null);
const [popScreenSearchText, setPopScreenSearchText] = useState("");
const [isPopScreenDropdownOpen, setIsPopScreenDropdownOpen] = useState(false);
const [isPopLanding, setIsPopLanding] = useState(false);
const [hasOtherPopLanding, setHasOtherPopLanding] = useState(false);
// 대시보드 할당 관련 상태
const [selectedDashboard, setSelectedDashboard] = useState<any | null>(null);
const [dashboards, setDashboards] = useState<any[]>([]);
@@ -196,8 +203,27 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
toast.success(`대시보드가 선택되었습니다: ${dashboard.title}`);
};
// POP 화면 선택 시 URL 자동 설정
const handlePopScreenSelect = (screen: ScreenDefinition) => {
const actualScreenId = screen.screenId || screen.id;
if (!actualScreenId) {
toast.error("화면 ID를 찾을 수 없습니다.");
return;
}
setSelectedPopScreen(screen);
setIsPopScreenDropdownOpen(false);
const popUrl = `/pop/screens/${actualScreenId}`;
setFormData((prev) => ({
...prev,
menuUrl: popUrl,
}));
};
// URL 타입 변경 시 처리
const handleUrlTypeChange = (type: "direct" | "screen" | "dashboard") => {
const handleUrlTypeChange = (type: "direct" | "screen" | "dashboard" | "pop") => {
// console.log("🔄 URL 타입 변경:", {
// from: urlType,
// to: type,
@@ -208,36 +234,53 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
setUrlType(type);
if (type === "direct") {
// 직접 입력 모드로 변경 시 선택된 화면 초기화
setSelectedScreen(null);
// URL 필드와 screenCode 초기화 (사용자가 직접 입력할 수 있도록)
setSelectedPopScreen(null);
setFormData((prev) => ({
...prev,
menuUrl: "",
screenCode: undefined, // 화면 코드도 함께 초기화
screenCode: undefined,
}));
} else {
// 화면 할당 모드로 변경 시
// 기존에 선택된 화면이 있고, 해당 화면의 URL이 있다면 유지
} else if (type === "pop") {
setSelectedScreen(null);
if (selectedPopScreen) {
const actualScreenId = selectedPopScreen.screenId || selectedPopScreen.id;
setFormData((prev) => ({
...prev,
menuUrl: `/pop/screens/${actualScreenId}`,
}));
} else {
setFormData((prev) => ({
...prev,
menuUrl: "",
}));
}
} else if (type === "screen") {
setSelectedPopScreen(null);
if (selectedScreen) {
console.log("📋 기존 선택된 화면 유지:", selectedScreen.screenName);
// 현재 선택된 화면으로 URL 재생성
const actualScreenId = selectedScreen.screenId || selectedScreen.id;
let screenUrl = `/screens/${actualScreenId}`;
// 관리자 메뉴인 경우 mode=admin 파라미터 추가
const isAdminMenu = menuType === "0" || menuType === "admin" || formData.menuType === "0";
if (isAdminMenu) {
screenUrl += "?mode=admin";
}
setFormData((prev) => ({
...prev,
menuUrl: screenUrl,
screenCode: selectedScreen.screenCode, // 화면 코드도 함께 유지
screenCode: selectedScreen.screenCode,
}));
} else {
// 선택된 화면이 없으면 URL과 screenCode 초기화
setFormData((prev) => ({
...prev,
menuUrl: "",
screenCode: undefined,
}));
}
} else {
// dashboard
setSelectedScreen(null);
setSelectedPopScreen(null);
if (!selectedDashboard) {
setFormData((prev) => ({
...prev,
menuUrl: "",
@@ -297,8 +340,8 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
const menuUrl = menu.menu_url || menu.MENU_URL || "";
// URL이 "/screens/"로 시작하면 화면 할당으로 판단 (실제 라우팅 패턴에 맞게 수정)
const isScreenUrl = menuUrl.startsWith("/screens/");
const isPopScreenUrl = menuUrl.startsWith("/pop/screens/");
const isScreenUrl = !isPopScreenUrl && menuUrl.startsWith("/screens/");
setFormData({
objid: menu.objid || menu.OBJID,
@@ -360,10 +403,31 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
}, 500);
}
}
} else if (isPopScreenUrl) {
setUrlType("pop");
setSelectedScreen(null);
// [POP_LANDING] 태그 감지
const menuDesc = menu.menu_desc || menu.MENU_DESC || "";
setIsPopLanding(menuDesc.includes("[POP_LANDING]"));
const popScreenId = menuUrl.match(/\/pop\/screens\/(\d+)/)?.[1];
if (popScreenId) {
const setPopScreenFromId = () => {
const screen = screens.find((s) => s.screenId.toString() === popScreenId || s.id?.toString() === popScreenId);
if (screen) {
setSelectedPopScreen(screen);
}
};
if (screens.length > 0) {
setPopScreenFromId();
} else {
setTimeout(setPopScreenFromId, 500);
}
}
} else if (menuUrl.startsWith("/dashboard/")) {
setUrlType("dashboard");
setSelectedScreen(null);
// 대시보드 ID 추출 및 선택은 useEffect에서 처리됨
} else {
setUrlType("direct");
setSelectedScreen(null);
@@ -408,6 +472,7 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
} else {
console.log("메뉴 등록 모드 - parentId:", parentId, "menuType:", menuType);
setIsEdit(false);
setIsPopLanding(false);
// 메뉴 타입 변환 (0 -> 0, 1 -> 1, admin -> 0, user -> 1)
let defaultMenuType = "1"; // 기본값은 사용자
@@ -470,6 +535,31 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
}
}, [isOpen, formData.companyCode]);
// POP 기본 화면 중복 체크: 같은 부모 하위에 이미 [POP_LANDING]이 있는 다른 메뉴가 있는지 확인
useEffect(() => {
if (!isOpen) return;
const checkOtherPopLanding = async () => {
try {
const res = await menuApi.getPopMenus();
if (res.success && res.data?.landingMenu) {
const landingObjId = res.data.landingMenu.objid?.toString();
const currentObjId = formData.objid?.toString();
// 현재 수정 중인 메뉴가 아닌 다른 메뉴에 [POP_LANDING]이 있으면 중복
setHasOtherPopLanding(!!landingObjId && landingObjId !== currentObjId);
} else {
setHasOtherPopLanding(false);
}
} catch {
setHasOtherPopLanding(false);
}
};
if (urlType === "pop") {
checkOtherPopLanding();
}
}, [isOpen, urlType, formData.objid]);
// 화면 목록 및 대시보드 목록 로드
useEffect(() => {
if (isOpen) {
@@ -517,6 +607,22 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
}
}, [dashboards, isEdit, formData.menuUrl, urlType, selectedDashboard]);
// POP 화면 목록 로드 완료 후 기존 할당 설정
useEffect(() => {
if (screens.length > 0 && isEdit && formData.menuUrl && urlType === "pop") {
const menuUrl = formData.menuUrl;
if (menuUrl.startsWith("/pop/screens/")) {
const popScreenId = menuUrl.match(/\/pop\/screens\/(\d+)/)?.[1];
if (popScreenId && !selectedPopScreen) {
const screen = screens.find((s) => s.screenId.toString() === popScreenId || s.id?.toString() === popScreenId);
if (screen) {
setSelectedPopScreen(screen);
}
}
}
}
}, [screens, isEdit, formData.menuUrl, urlType, selectedPopScreen]);
// 드롭다운 외부 클릭 시 닫기
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
@@ -533,16 +639,20 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
setIsDashboardDropdownOpen(false);
setDashboardSearchText("");
}
if (!target.closest(".pop-screen-dropdown")) {
setIsPopScreenDropdownOpen(false);
setPopScreenSearchText("");
}
};
if (isLangKeyDropdownOpen || isScreenDropdownOpen || isDashboardDropdownOpen) {
if (isLangKeyDropdownOpen || isScreenDropdownOpen || isDashboardDropdownOpen || isPopScreenDropdownOpen) {
document.addEventListener("mousedown", handleClickOutside);
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [isLangKeyDropdownOpen, isScreenDropdownOpen]);
}, [isLangKeyDropdownOpen, isScreenDropdownOpen, isDashboardDropdownOpen, isPopScreenDropdownOpen]);
const loadCompanies = async () => {
try {
@@ -590,10 +700,17 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
try {
setLoading(true);
// POP 기본 화면 태그 처리
let finalMenuDesc = formData.menuDesc;
if (urlType === "pop") {
const descWithoutTag = finalMenuDesc.replace(/\[POP_LANDING\]/g, "").trim();
finalMenuDesc = isPopLanding ? `${descWithoutTag} [POP_LANDING]`.trim() : descWithoutTag;
}
// 백엔드에 전송할 데이터 변환
const submitData = {
...formData,
// 상태를 소문자로 변환 (백엔드에서 소문자 기대)
menuDesc: finalMenuDesc,
status: formData.status.toLowerCase(),
};
@@ -853,7 +970,7 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
<Label htmlFor="menuUrl">{getText(MENU_MANAGEMENT_KEYS.FORM_MENU_URL)}</Label>
{/* URL 타입 선택 */}
<RadioGroup value={urlType} onValueChange={handleUrlTypeChange} className="mb-3 flex space-x-6">
<RadioGroup value={urlType} onValueChange={handleUrlTypeChange} className="mb-3 flex flex-wrap gap-x-6 gap-y-2">
<div className="flex items-center space-x-2">
<RadioGroupItem value="screen" id="screen" />
<Label htmlFor="screen" className="cursor-pointer">
@@ -866,6 +983,12 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="pop" id="pop" />
<Label htmlFor="pop" className="cursor-pointer">
POP
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="direct" id="direct" />
<Label htmlFor="direct" className="cursor-pointer">
@@ -1031,6 +1154,106 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
</div>
)}
{/* POP 화면 할당 */}
{urlType === "pop" && (
<div className="space-y-2">
<div className="relative">
<Button
type="button"
variant="outline"
onClick={() => setIsPopScreenDropdownOpen(!isPopScreenDropdownOpen)}
className="w-full justify-between"
>
<span className="text-left">
{selectedPopScreen ? selectedPopScreen.screenName : "POP 화면을 선택하세요"}
</span>
<ChevronDown className="h-4 w-4" />
</Button>
{isPopScreenDropdownOpen && (
<div className="pop-screen-dropdown absolute top-full right-0 left-0 z-50 mt-1 max-h-60 overflow-y-auto rounded-md border bg-white shadow-lg">
<div className="sticky top-0 border-b bg-white p-2">
<div className="relative">
<Search className="absolute top-1/2 left-2 h-4 w-4 -translate-y-1/2 text-gray-400" />
<Input
placeholder="POP 화면 검색..."
value={popScreenSearchText}
onChange={(e) => setPopScreenSearchText(e.target.value)}
className="pl-8"
/>
</div>
</div>
<div className="max-h-48 overflow-y-auto">
{screens
.filter(
(screen) =>
screen.screenName.toLowerCase().includes(popScreenSearchText.toLowerCase()) ||
screen.screenCode.toLowerCase().includes(popScreenSearchText.toLowerCase()),
)
.map((screen, index) => (
<div
key={`pop-screen-${screen.screenId || screen.id || index}-${screen.screenCode || index}`}
onClick={() => handlePopScreenSelect(screen)}
className="cursor-pointer border-b px-3 py-2 last:border-b-0 hover:bg-gray-100"
>
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium">{screen.screenName}</div>
<div className="text-xs text-gray-500">{screen.screenCode}</div>
</div>
<div className="text-xs text-gray-400">ID: {screen.screenId || screen.id || "N/A"}</div>
</div>
</div>
))}
{screens.filter(
(screen) =>
screen.screenName.toLowerCase().includes(popScreenSearchText.toLowerCase()) ||
screen.screenCode.toLowerCase().includes(popScreenSearchText.toLowerCase()),
).length === 0 && <div className="px-3 py-2 text-sm text-gray-500"> .</div>}
</div>
</div>
)}
</div>
{selectedPopScreen && (
<div className="bg-accent rounded-md border p-3">
<div className="text-sm font-medium text-blue-900">{selectedPopScreen.screenName}</div>
<div className="text-primary text-xs">: {selectedPopScreen.screenCode}</div>
<div className="text-primary text-xs"> URL: {formData.menuUrl}</div>
</div>
)}
{/* POP 기본 화면 설정 */}
<div className="flex items-center space-x-2 rounded-md border p-3">
<input
type="checkbox"
id="popLanding"
checked={isPopLanding}
disabled={!isPopLanding && hasOtherPopLanding}
onChange={(e) => setIsPopLanding(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 accent-primary disabled:cursor-not-allowed disabled:opacity-50"
/>
<label
htmlFor="popLanding"
className={`text-sm font-medium ${!isPopLanding && hasOtherPopLanding ? "text-muted-foreground" : ""}`}
>
POP
</label>
{!isPopLanding && hasOtherPopLanding && (
<span className="text-xs text-muted-foreground">
( )
</span>
)}
</div>
{isPopLanding && (
<p className="text-xs text-muted-foreground">
POP .
</p>
)}
</div>
)}
{/* URL 직접 입력 */}
{urlType === "direct" && (
<Input
+19 -1
View File
@@ -2,7 +2,8 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Eye, EyeOff, Loader2 } from "lucide-react";
import { Switch } from "@/components/ui/switch";
import { Eye, EyeOff, Loader2, Monitor } from "lucide-react";
import { LoginFormData } from "@/types/auth";
import { ErrorMessage } from "./ErrorMessage";
@@ -11,9 +12,11 @@ interface LoginFormProps {
isLoading: boolean;
error: string;
showPassword: boolean;
isPopMode: boolean;
onInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
onSubmit: (e: React.FormEvent) => void;
onTogglePassword: () => void;
onTogglePop: () => void;
}
/**
@@ -24,9 +27,11 @@ export function LoginForm({
isLoading,
error,
showPassword,
isPopMode,
onInputChange,
onSubmit,
onTogglePassword,
onTogglePop,
}: LoginFormProps) {
return (
<Card className="border shadow-lg">
@@ -82,6 +87,19 @@ export function LoginForm({
</div>
</div>
{/* POP 모드 토글 */}
<div className="flex items-center justify-between rounded-lg border border-slate-200 bg-slate-50 px-3 py-2.5">
<div className="flex items-center gap-2">
<Monitor className="h-4 w-4 text-slate-500" />
<span className="text-sm text-slate-600">POP </span>
</div>
<Switch
checked={isPopMode}
onCheckedChange={onTogglePop}
disabled={isLoading}
/>
</div>
{/* 로그인 버튼 */}
<Button
type="submit"
@@ -42,9 +42,12 @@ export const BarcodeScanModal: React.FC<BarcodeScanModalProps> = ({
const codeReaderRef = useRef<BrowserMultiFormatReader | null>(null);
const scanIntervalRef = useRef<NodeJS.Timeout | null>(null);
// 바코드 리더 초기화
// 바코드 리더 초기화 + 모달 열릴 때 상태 리셋
useEffect(() => {
if (open) {
setScannedCode("");
setError("");
setIsScanning(false);
codeReaderRef.current = new BrowserMultiFormatReader();
}
@@ -276,7 +279,7 @@ export const BarcodeScanModal: React.FC<BarcodeScanModalProps> = ({
{/* 스캔 가이드 오버레이 */}
{isScanning && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="h-32 w-32 border-4 border-primary animate-pulse rounded-lg" />
<div className="h-3/5 w-4/5 rounded-lg border-4 border-primary/70 animate-pulse" />
<div className="absolute bottom-4 left-0 right-0 text-center">
<div className="inline-flex items-center gap-2 rounded-full bg-background/80 px-4 py-2 text-xs font-medium">
<Scan className="h-4 w-4 animate-pulse text-primary" />
@@ -355,6 +358,20 @@ export const BarcodeScanModal: React.FC<BarcodeScanModalProps> = ({
</Button>
)}
{scannedCode && (
<Button
variant="outline"
onClick={() => {
setScannedCode("");
startScanning();
}}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
<Camera className="mr-2 h-4 w-4" />
</Button>
)}
{scannedCode && !autoSubmit && (
<Button
onClick={handleConfirm}
+35 -1
View File
@@ -19,11 +19,12 @@ import {
User,
Building2,
FileCheck,
Monitor,
} from "lucide-react";
import { useMenu } from "@/contexts/MenuContext";
import { useAuth } from "@/hooks/useAuth";
import { useProfile } from "@/hooks/useProfile";
import { MenuItem } from "@/lib/api/menu";
import { MenuItem, menuApi } from "@/lib/api/menu";
import { menuScreenApi } from "@/lib/api/screen";
import { apiClient } from "@/lib/api/client";
import { toast } from "sonner";
@@ -453,6 +454,31 @@ function AppLayoutInner({ children }: AppLayoutProps) {
e.dataTransfer.setData("text/plain", menuName);
};
// POP 모드 진입 핸들러
const handlePopModeClick = async () => {
try {
const response = await menuApi.getPopMenus();
if (response.success && response.data) {
const { childMenus, landingMenu } = response.data;
if (landingMenu?.menu_url) {
router.push(landingMenu.menu_url);
} else if (childMenus.length === 0) {
toast.info("설정된 POP 화면이 없습니다");
} else if (childMenus.length === 1) {
router.push(childMenus[0].menu_url);
} else {
router.push("/pop");
}
} else {
toast.info("설정된 POP 화면이 없습니다");
}
} catch (error) {
toast.error("POP 메뉴 조회 중 오류가 발생했습니다");
}
};
// 메뉴 트리 렌더링 (기존 MainLayout 스타일 적용)
const renderMenu = (menu: any, level: number = 0) => {
const isExpanded = expandedMenus.has(menu.id);
const isLeaf = !menu.hasChildren;
@@ -576,6 +602,10 @@ function AppLayoutInner({ children }: AppLayoutProps) {
<FileCheck className="mr-2 h-4 w-4" />
<span></span>
</DropdownMenuItem>
<DropdownMenuItem onClick={handlePopModeClick}>
<Monitor className="mr-2 h-4 w-4" />
<span>POP </span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<div className="px-1 py-0.5">
<ThemeToggle />
@@ -748,6 +778,10 @@ function AppLayoutInner({ children }: AppLayoutProps) {
<FileCheck className="mr-2 h-4 w-4" />
<span></span>
</DropdownMenuItem>
<DropdownMenuItem onClick={handlePopModeClick}>
<Monitor className="mr-2 h-4 w-4" />
<span>POP </span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout}>
<LogOut className="mr-2 h-4 w-4" />
+3 -2
View File
@@ -6,13 +6,14 @@ interface MainHeaderProps {
user: any;
onSidebarToggle: () => void;
onProfileClick: () => void;
onPopModeClick?: () => void;
onLogout: () => void;
}
/**
* 메인 헤더 컴포넌트
*/
export function MainHeader({ user, onSidebarToggle, onProfileClick, onLogout }: MainHeaderProps) {
export function MainHeader({ user, onSidebarToggle, onProfileClick, onPopModeClick, onLogout }: MainHeaderProps) {
return (
<header className="bg-background/95 fixed top-0 z-50 h-14 min-h-14 w-full flex-shrink-0 border-b backdrop-blur">
<div className="flex h-full w-full items-center justify-between px-6">
@@ -27,7 +28,7 @@ export function MainHeader({ user, onSidebarToggle, onProfileClick, onLogout }:
{/* Right side - Admin Button + User Menu */}
<div className="flex h-8 items-center gap-2">
<UserDropdown user={user} onProfileClick={onProfileClick} onLogout={onLogout} />
<UserDropdown user={user} onProfileClick={onProfileClick} onPopModeClick={onPopModeClick} onLogout={onLogout} />
</div>
</div>
</header>
+12
View File
@@ -19,6 +19,11 @@ import {
clearTabCache,
} from "@/lib/tabStateCache";
// 페이지 로드(F5 새로고침) 감지용 모듈 레벨 플래그.
// 전체 페이지 로드 시 모듈이 재실행되어 false로 리셋된다.
// SPA 내비게이션에서는 모듈이 유지되므로 true로 남는다.
let hasHandledPageLoad = false;
export function TabContent() {
const tabs = useTabStore(selectTabs);
const activeTabId = useTabStore(selectActiveTabId);
@@ -39,6 +44,13 @@ export function TabContent() {
// 요소 → 경로 캐시 (매 스크롤 이벤트마다 경로를 재계산하지 않기 위함)
const pathCacheRef = useRef<WeakMap<HTMLElement, string | null>>(new WeakMap());
// 페이지 로드(F5) 시 활성 탭 캐시만 삭제 → fresh API 호출 유도
// 비활성 탭 캐시는 유지하여 탭 전환 시 복원
if (!hasHandledPageLoad && activeTabId) {
hasHandledPageLoad = true;
clearTabCache(activeTabId);
}
if (activeTabId) {
mountedTabIdsRef.current.add(activeTabId);
}
+9 -3
View File
@@ -8,19 +8,20 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { LogOut, User, FileCheck } from "lucide-react";
import { LogOut, FileCheck, Monitor, User } from "lucide-react";
import { useRouter } from "next/navigation";
interface UserDropdownProps {
user: any;
onProfileClick: () => void;
onPopModeClick?: () => void;
onLogout: () => void;
}
/**
* 사용자 드롭다운 메뉴 컴포넌트
*/
export function UserDropdown({ user, onProfileClick, onLogout }: UserDropdownProps) {
export function UserDropdown({ user, onProfileClick, onPopModeClick, onLogout }: UserDropdownProps) {
const router = useRouter();
if (!user) return null;
@@ -73,7 +74,6 @@ export function UserDropdown({ user, onProfileClick, onLogout }: UserDropdownPro
? `${user.deptName}, ${user.positionName}`
: user.deptName || user.positionName || "부서 정보 없음"}
</p>
{/* 사진 상태 표시 */}
</div>
</div>
</DropdownMenuLabel>
@@ -86,6 +86,12 @@ export function UserDropdown({ user, onProfileClick, onLogout }: UserDropdownPro
<FileCheck className="mr-2 h-4 w-4" />
<span></span>
</DropdownMenuItem>
{onPopModeClick && (
<DropdownMenuItem onClick={onPopModeClick}>
<Monitor className="mr-2 h-4 w-4" />
<span>POP </span>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onLogout}>
<LogOut className="mr-2 h-4 w-4" />
@@ -1,7 +1,7 @@
"use client";
import React, { useState, useEffect } from "react";
import { Moon, Sun } from "lucide-react";
import { Moon, Sun, Monitor } from "lucide-react";
import { WeatherInfo, UserInfo, CompanyInfo } from "./types";
interface DashboardHeaderProps {
@@ -11,6 +11,7 @@ interface DashboardHeaderProps {
company: CompanyInfo;
onThemeToggle: () => void;
onUserClick: () => void;
onPcModeClick?: () => void;
}
export function DashboardHeader({
@@ -20,6 +21,7 @@ export function DashboardHeader({
company,
onThemeToggle,
onUserClick,
onPcModeClick,
}: DashboardHeaderProps) {
const [mounted, setMounted] = useState(false);
const [currentTime, setCurrentTime] = useState(new Date());
@@ -81,6 +83,17 @@ export function DashboardHeader({
<div className="pop-dashboard-company-sub">{company.subTitle}</div>
</div>
{/* PC 모드 복귀 */}
{onPcModeClick && (
<button
className="pop-dashboard-theme-toggle"
onClick={onPcModeClick}
title="PC 모드로 돌아가기"
>
<Monitor size={16} />
</button>
)}
{/* 사용자 배지 */}
<button className="pop-dashboard-user-badge" onClick={onUserClick}>
<div className="pop-dashboard-user-avatar">{user.avatar}</div>
@@ -1,6 +1,7 @@
"use client";
import React, { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { DashboardHeader } from "./DashboardHeader";
import { NoticeBanner } from "./NoticeBanner";
import { KpiBar } from "./KpiBar";
@@ -8,6 +9,8 @@ import { MenuGrid } from "./MenuGrid";
import { ActivityList } from "./ActivityList";
import { NoticeList } from "./NoticeList";
import { DashboardFooter } from "./DashboardFooter";
import { MenuItem as DashboardMenuItem } from "./types";
import { menuApi, PopMenuItem } from "@/lib/api/menu";
import {
KPI_ITEMS,
MENU_ITEMS,
@@ -17,10 +20,31 @@ import {
} from "./data";
import "./dashboard.css";
export function PopDashboard() {
const [theme, setTheme] = useState<"dark" | "light">("dark");
const CATEGORY_COLORS: DashboardMenuItem["category"][] = [
"production",
"material",
"quality",
"equipment",
"safety",
];
function convertPopMenuToMenuItem(item: PopMenuItem, index: number): DashboardMenuItem {
return {
id: item.objid,
title: item.menu_name_kor,
count: 0,
description: item.menu_desc?.replace("[POP]", "").trim() || "",
status: "",
category: CATEGORY_COLORS[index % CATEGORY_COLORS.length],
href: item.menu_url || "#",
};
}
export function PopDashboard() {
const router = useRouter();
const [theme, setTheme] = useState<"dark" | "light">("dark");
const [menuItems, setMenuItems] = useState<DashboardMenuItem[]>(MENU_ITEMS);
// 로컬 스토리지에서 테마 로드
useEffect(() => {
const savedTheme = localStorage.getItem("popTheme") as "dark" | "light" | null;
if (savedTheme) {
@@ -28,6 +52,22 @@ export function PopDashboard() {
}
}, []);
// API에서 POP 메뉴 로드
useEffect(() => {
const loadPopMenus = async () => {
try {
const response = await menuApi.getPopMenus();
if (response.success && response.data && response.data.childMenus.length > 0) {
const converted = response.data.childMenus.map(convertPopMenuToMenuItem);
setMenuItems(converted);
}
} catch {
// API 실패 시 기존 하드코딩 데이터 유지
}
};
loadPopMenus();
}, []);
const handleThemeToggle = () => {
const newTheme = theme === "dark" ? "light" : "dark";
setTheme(newTheme);
@@ -40,6 +80,10 @@ export function PopDashboard() {
}
};
const handlePcModeClick = () => {
router.push("/");
};
const handleActivityMore = () => {
alert("전체 활동 내역 화면으로 이동합니다.");
};
@@ -58,13 +102,14 @@ export function PopDashboard() {
company={{ name: "탑씰", subTitle: "현장 관리 시스템" }}
onThemeToggle={handleThemeToggle}
onUserClick={handleUserClick}
onPcModeClick={handlePcModeClick}
/>
<NoticeBanner text={NOTICE_MARQUEE_TEXT} />
<KpiBar items={KPI_ITEMS} />
<MenuGrid items={MENU_ITEMS} />
<MenuGrid items={menuItems} />
<div className="pop-dashboard-bottom-section">
<ActivityList items={ACTIVITY_ITEMS} onMoreClick={handleActivityMore} />
@@ -150,7 +150,7 @@ export default function PopDesigner({
try {
const loadedLayout = await screenApi.getLayoutPop(selectedScreen.screenId);
if (loadedLayout && isV5Layout(loadedLayout) && Object.keys(loadedLayout.components).length > 0) {
if (loadedLayout && isV5Layout(loadedLayout) && loadedLayout.components && Object.keys(loadedLayout.components).length > 0) {
// v5 레이아웃 로드
// 기존 레이아웃 호환성: gapPreset이 없으면 기본값 추가
if (!loadedLayout.settings.gapPreset) {
@@ -69,10 +69,12 @@ const COMPONENT_TYPE_LABELS: Record<string, string> = {
"pop-icon": "아이콘",
"pop-dashboard": "대시보드",
"pop-card-list": "카드 목록",
"pop-card-list-v2": "카드 목록 V2",
"pop-field": "필드",
"pop-button": "버튼",
"pop-string-list": "리스트 목록",
"pop-search": "검색",
"pop-status-bar": "상태 바",
"pop-list": "리스트",
"pop-indicator": "인디케이터",
"pop-scanner": "스캐너",
@@ -169,9 +171,7 @@ export default function ComponentEditorPanel({
</div>
<div className="space-y-1">
{allComponents.map((comp) => {
const label = comp.label
|| COMPONENT_TYPE_LABELS[comp.type]
|| comp.type;
const label = comp.label || comp.id;
const isActive = comp.id === selectedComponentId;
return (
<button
@@ -3,7 +3,7 @@
import { useDrag } from "react-dnd";
import { cn } from "@/lib/utils";
import { PopComponentType } from "../types/pop-layout";
import { Square, FileText, MousePointer, BarChart3, LayoutGrid, MousePointerClick, List, Search, TextCursorInput } from "lucide-react";
import { Square, FileText, MousePointer, BarChart3, LayoutGrid, MousePointerClick, List, Search, TextCursorInput, ScanLine, UserCircle, BarChart2 } from "lucide-react";
import { DND_ITEM_TYPES } from "../constants";
// 컴포넌트 정의
@@ -45,6 +45,12 @@ const PALETTE_ITEMS: PaletteItem[] = [
icon: LayoutGrid,
description: "테이블 데이터를 카드 형태로 표시",
},
{
type: "pop-card-list-v2",
label: "카드 목록 V2",
icon: LayoutGrid,
description: "슬롯 기반 카드 (CSS Grid + 셀 타입별 렌더링)",
},
{
type: "pop-button",
label: "버튼",
@@ -63,12 +69,30 @@ const PALETTE_ITEMS: PaletteItem[] = [
icon: Search,
description: "조건 입력 (텍스트/날짜/선택/모달)",
},
{
type: "pop-status-bar",
label: "상태 바",
icon: BarChart2,
description: "상태별 건수 대시보드 + 필터",
},
{
type: "pop-field",
label: "입력 필드",
icon: TextCursorInput,
description: "저장용 값 입력 (섹션별 멀티필드)",
},
{
type: "pop-scanner",
label: "스캐너",
icon: ScanLine,
description: "바코드/QR 카메라 스캔",
},
{
type: "pop-profile",
label: "프로필",
icon: UserCircle,
description: "사용자 프로필 / PC 전환 / 로그아웃",
},
];
// 드래그 가능한 컴포넌트 아이템
@@ -4,7 +4,6 @@ import React from "react";
import { ArrowRight, Link2, Unlink2, Plus, Trash2, Pencil, X, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import {
Select,
@@ -19,7 +18,6 @@ import {
} from "../types/pop-layout";
import {
PopComponentRegistry,
type ComponentConnectionMeta,
} from "@/lib/registry/PopComponentRegistry";
import { getTableColumns } from "@/lib/api/tableManagement";
@@ -36,15 +34,6 @@ interface ConnectionEditorProps {
onRemoveConnection?: (connectionId: string) => void;
}
// ========================================
// 소스 컴포넌트에 filter 타입 sendable이 있는지 판단
// ========================================
function hasFilterSendable(meta: ComponentConnectionMeta | undefined): boolean {
if (!meta?.sendable) return false;
return meta.sendable.some((s) => s.category === "filter" || s.type === "filter_value");
}
// ========================================
// ConnectionEditor
// ========================================
@@ -84,17 +73,13 @@ export default function ConnectionEditor({
);
}
const isFilterSource = hasFilterSendable(meta);
return (
<div className="space-y-6">
{hasSendable && (
<SendSection
component={component}
meta={meta!}
allComponents={allComponents}
outgoing={outgoing}
isFilterSource={isFilterSource}
onAddConnection={onAddConnection}
onUpdateConnection={onUpdateConnection}
onRemoveConnection={onRemoveConnection}
@@ -112,47 +97,14 @@ export default function ConnectionEditor({
);
}
// ========================================
// 대상 컴포넌트에서 정보 추출
// ========================================
function extractDisplayColumns(comp: PopComponentDefinitionV5 | undefined): string[] {
if (!comp?.config) return [];
const cfg = comp.config as Record<string, unknown>;
const cols: string[] = [];
if (Array.isArray(cfg.listColumns)) {
(cfg.listColumns as Array<{ columnName?: string }>).forEach((c) => {
if (c.columnName && !cols.includes(c.columnName)) cols.push(c.columnName);
});
}
if (Array.isArray(cfg.selectedColumns)) {
(cfg.selectedColumns as string[]).forEach((c) => {
if (!cols.includes(c)) cols.push(c);
});
}
return cols;
}
function extractTableName(comp: PopComponentDefinitionV5 | undefined): string {
if (!comp?.config) return "";
const cfg = comp.config as Record<string, unknown>;
const ds = cfg.dataSource as { tableName?: string } | undefined;
return ds?.tableName || "";
}
// ========================================
// 보내기 섹션
// ========================================
interface SendSectionProps {
component: PopComponentDefinitionV5;
meta: ComponentConnectionMeta;
allComponents: PopComponentDefinitionV5[];
outgoing: PopDataConnection[];
isFilterSource: boolean;
onAddConnection?: (conn: Omit<PopDataConnection, "id">) => void;
onUpdateConnection?: (connectionId: string, conn: Omit<PopDataConnection, "id">) => void;
onRemoveConnection?: (connectionId: string) => void;
@@ -160,10 +112,8 @@ interface SendSectionProps {
function SendSection({
component,
meta,
allComponents,
outgoing,
isFilterSource,
onAddConnection,
onUpdateConnection,
onRemoveConnection,
@@ -180,34 +130,20 @@ function SendSection({
{outgoing.map((conn) => (
<div key={conn.id}>
{editingId === conn.id ? (
isFilterSource ? (
<FilterConnectionForm
component={component}
meta={meta}
allComponents={allComponents}
initial={conn}
onSubmit={(data) => {
onUpdateConnection?.(conn.id, data);
setEditingId(null);
}}
onCancel={() => setEditingId(null)}
submitLabel="수정"
/>
) : (
<SimpleConnectionForm
component={component}
allComponents={allComponents}
initial={conn}
onSubmit={(data) => {
onUpdateConnection?.(conn.id, data);
setEditingId(null);
}}
onCancel={() => setEditingId(null)}
submitLabel="수정"
/>
)
<SimpleConnectionForm
component={component}
allComponents={allComponents}
initial={conn}
onSubmit={(data) => {
onUpdateConnection?.(conn.id, data);
setEditingId(null);
}}
onCancel={() => setEditingId(null)}
submitLabel="수정"
/>
) : (
<div className="flex items-center gap-1 rounded border bg-primary/10/50 px-3 py-2">
<div className="space-y-1 rounded border bg-primary/10 px-3 py-2">
<div className="flex items-center gap-1">
<span className="flex-1 truncate text-xs">
{conn.label || `${allComponents.find((c) => c.id === conn.targetComponent)?.label || conn.targetComponent}`}
</span>
@@ -225,27 +161,33 @@ function SendSection({
<Trash2 className="h-3 w-3" />
</button>
)}
</div>
{conn.filterConfig?.targetColumn && (
<div className="flex flex-wrap gap-1">
<span className="rounded bg-white px-1.5 py-0.5 text-[9px] text-muted-foreground">
{conn.filterConfig.targetColumn}
</span>
<span className="rounded bg-white px-1.5 py-0.5 text-[9px] text-muted-foreground">
{conn.filterConfig.filterMode}
</span>
{conn.filterConfig.isSubTable && (
<span className="rounded bg-amber-100 px-1.5 py-0.5 text-[9px] text-amber-700">
</span>
)}
</div>
)}
</div>
)}
</div>
))}
{isFilterSource ? (
<FilterConnectionForm
component={component}
meta={meta}
allComponents={allComponents}
onSubmit={(data) => onAddConnection?.(data)}
submitLabel="연결 추가"
/>
) : (
<SimpleConnectionForm
component={component}
allComponents={allComponents}
onSubmit={(data) => onAddConnection?.(data)}
submitLabel="연결 추가"
/>
)}
<SimpleConnectionForm
component={component}
allComponents={allComponents}
onSubmit={(data) => onAddConnection?.(data)}
submitLabel="연결 추가"
/>
</div>
);
}
@@ -263,6 +205,19 @@ interface SimpleConnectionFormProps {
submitLabel: string;
}
function extractSubTableName(comp: PopComponentDefinitionV5): string | null {
const cfg = comp.config as Record<string, unknown> | undefined;
if (!cfg) return null;
const grid = cfg.cardGrid as { cells?: Array<{ timelineSource?: { processTable?: string } }> } | undefined;
if (grid?.cells) {
for (const cell of grid.cells) {
if (cell.timelineSource?.processTable) return cell.timelineSource.processTable;
}
}
return null;
}
function SimpleConnectionForm({
component,
allComponents,
@@ -274,6 +229,18 @@ function SimpleConnectionForm({
const [selectedTargetId, setSelectedTargetId] = React.useState(
initial?.targetComponent || ""
);
const [isSubTable, setIsSubTable] = React.useState(
initial?.filterConfig?.isSubTable || false
);
const [targetColumn, setTargetColumn] = React.useState(
initial?.filterConfig?.targetColumn || ""
);
const [filterMode, setFilterMode] = React.useState<string>(
initial?.filterConfig?.filterMode || "equals"
);
const [subColumns, setSubColumns] = React.useState<string[]>([]);
const [loadingColumns, setLoadingColumns] = React.useState(false);
const targetCandidates = allComponents.filter((c) => {
if (c.id === component.id) return false;
@@ -281,14 +248,39 @@ function SimpleConnectionForm({
return reg?.connectionMeta?.receivable && reg.connectionMeta.receivable.length > 0;
});
const sourceReg = PopComponentRegistry.getComponent(component.type);
const targetComp = allComponents.find((c) => c.id === selectedTargetId);
const targetReg = targetComp ? PopComponentRegistry.getComponent(targetComp.type) : null;
const isFilterConnection = sourceReg?.connectionMeta?.sendable?.some((s) => s.type === "filter_value")
&& targetReg?.connectionMeta?.receivable?.some((r) => r.type === "filter_value");
const subTableName = targetComp ? extractSubTableName(targetComp) : null;
React.useEffect(() => {
if (!isSubTable || !subTableName) {
setSubColumns([]);
return;
}
setLoadingColumns(true);
getTableColumns(subTableName)
.then((res) => {
const cols = res.success && res.data?.columns;
if (Array.isArray(cols)) {
setSubColumns(cols.map((c) => c.columnName || "").filter(Boolean));
}
})
.catch(() => setSubColumns([]))
.finally(() => setLoadingColumns(false));
}, [isSubTable, subTableName]);
const handleSubmit = () => {
if (!selectedTargetId) return;
const targetComp = allComponents.find((c) => c.id === selectedTargetId);
const tComp = allComponents.find((c) => c.id === selectedTargetId);
const srcLabel = component.label || component.id;
const tgtLabel = targetComp?.label || targetComp?.id || "?";
const tgtLabel = tComp?.label || tComp?.id || "?";
onSubmit({
const conn: Omit<PopDataConnection, "id"> = {
sourceComponent: component.id,
sourceField: "",
sourceOutput: "_auto",
@@ -296,10 +288,23 @@ function SimpleConnectionForm({
targetField: "",
targetInput: "_auto",
label: `${srcLabel}${tgtLabel}`,
});
};
if (isFilterConnection && isSubTable && targetColumn) {
conn.filterConfig = {
targetColumn,
filterMode: filterMode as "equals" | "contains" | "starts_with" | "range",
isSubTable: true,
};
}
onSubmit(conn);
if (!initial) {
setSelectedTargetId("");
setIsSubTable(false);
setTargetColumn("");
setFilterMode("equals");
}
};
@@ -319,224 +324,12 @@ function SimpleConnectionForm({
<div className="space-y-1">
<span className="text-[10px] text-muted-foreground">?</span>
<Select
value={selectedTargetId}
onValueChange={setSelectedTargetId}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="컴포넌트 선택" />
</SelectTrigger>
<SelectContent>
{targetCandidates.map((c) => (
<SelectItem key={c.id} value={c.id} className="text-xs">
{c.label || c.id}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button
size="sm"
variant="outline"
className="h-7 w-full text-xs"
disabled={!selectedTargetId}
onClick={handleSubmit}
>
{!initial && <Plus className="mr-1 h-3 w-3" />}
{submitLabel}
</Button>
</div>
);
}
// ========================================
// 필터 연결 폼 (검색 컴포넌트용: 기존 UI 유지)
// ========================================
interface FilterConnectionFormProps {
component: PopComponentDefinitionV5;
meta: ComponentConnectionMeta;
allComponents: PopComponentDefinitionV5[];
initial?: PopDataConnection;
onSubmit: (data: Omit<PopDataConnection, "id">) => void;
onCancel?: () => void;
submitLabel: string;
}
function FilterConnectionForm({
component,
meta,
allComponents,
initial,
onSubmit,
onCancel,
submitLabel,
}: FilterConnectionFormProps) {
const [selectedOutput, setSelectedOutput] = React.useState(
initial?.sourceOutput || meta.sendable[0]?.key || ""
);
const [selectedTargetId, setSelectedTargetId] = React.useState(
initial?.targetComponent || ""
);
const [selectedTargetInput, setSelectedTargetInput] = React.useState(
initial?.targetInput || ""
);
const [filterColumns, setFilterColumns] = React.useState<string[]>(
initial?.filterConfig?.targetColumns ||
(initial?.filterConfig?.targetColumn ? [initial.filterConfig.targetColumn] : [])
);
const [filterMode, setFilterMode] = React.useState<
"equals" | "contains" | "starts_with" | "range"
>(initial?.filterConfig?.filterMode || "contains");
const targetCandidates = allComponents.filter((c) => {
if (c.id === component.id) return false;
const reg = PopComponentRegistry.getComponent(c.type);
return reg?.connectionMeta?.receivable && reg.connectionMeta.receivable.length > 0;
});
const targetComp = selectedTargetId
? allComponents.find((c) => c.id === selectedTargetId)
: null;
const targetMeta = targetComp
? PopComponentRegistry.getComponent(targetComp.type)?.connectionMeta
: null;
React.useEffect(() => {
if (!selectedOutput || !targetMeta?.receivable?.length) return;
if (selectedTargetInput) return;
const receivables = targetMeta.receivable;
const exactMatch = receivables.find((r) => r.key === selectedOutput);
if (exactMatch) {
setSelectedTargetInput(exactMatch.key);
return;
}
if (receivables.length === 1) {
setSelectedTargetInput(receivables[0].key);
}
}, [selectedOutput, targetMeta, selectedTargetInput]);
const displayColumns = React.useMemo(
() => extractDisplayColumns(targetComp || undefined),
[targetComp]
);
const tableName = React.useMemo(
() => extractTableName(targetComp || undefined),
[targetComp]
);
const [allDbColumns, setAllDbColumns] = React.useState<string[]>([]);
const [dbColumnsLoading, setDbColumnsLoading] = React.useState(false);
React.useEffect(() => {
if (!tableName) {
setAllDbColumns([]);
return;
}
let cancelled = false;
setDbColumnsLoading(true);
getTableColumns(tableName).then((res) => {
if (cancelled) return;
if (res.success && res.data?.columns) {
setAllDbColumns(res.data.columns.map((c) => c.columnName));
} else {
setAllDbColumns([]);
}
setDbColumnsLoading(false);
});
return () => { cancelled = true; };
}, [tableName]);
const displaySet = React.useMemo(() => new Set(displayColumns), [displayColumns]);
const dataOnlyColumns = React.useMemo(
() => allDbColumns.filter((c) => !displaySet.has(c)),
[allDbColumns, displaySet]
);
const hasAnyColumns = displayColumns.length > 0 || dataOnlyColumns.length > 0;
const toggleColumn = (col: string) => {
setFilterColumns((prev) =>
prev.includes(col) ? prev.filter((c) => c !== col) : [...prev, col]
);
};
const handleSubmit = () => {
if (!selectedOutput || !selectedTargetId || !selectedTargetInput) return;
const isEvent = isEventTypeConnection(meta, selectedOutput, targetMeta, selectedTargetInput);
onSubmit({
sourceComponent: component.id,
sourceField: "",
sourceOutput: selectedOutput,
targetComponent: selectedTargetId,
targetField: "",
targetInput: selectedTargetInput,
filterConfig:
!isEvent && filterColumns.length > 0
? {
targetColumn: filterColumns[0],
targetColumns: filterColumns,
filterMode,
}
: undefined,
label: buildConnectionLabel(
component,
selectedOutput,
allComponents.find((c) => c.id === selectedTargetId),
selectedTargetInput,
filterColumns
),
});
if (!initial) {
setSelectedTargetId("");
setSelectedTargetInput("");
setFilterColumns([]);
}
};
return (
<div className="space-y-2 rounded border border-dashed p-3">
{onCancel && (
<div className="flex items-center justify-between">
<p className="text-[10px] font-medium text-muted-foreground"> </p>
<button onClick={onCancel} className="text-muted-foreground hover:text-foreground">
<X className="h-3 w-3" />
</button>
</div>
)}
{!onCancel && (
<p className="text-[10px] font-medium text-muted-foreground"> </p>
)}
<div className="space-y-1">
<span className="text-[10px] text-muted-foreground"> </span>
<Select value={selectedOutput} onValueChange={setSelectedOutput}>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{meta.sendable.map((s) => (
<SelectItem key={s.key} value={s.key} className="text-xs">
{s.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<span className="text-[10px] text-muted-foreground"> </span>
<Select
value={selectedTargetId}
onValueChange={(v) => {
setSelectedTargetId(v);
setSelectedTargetInput("");
setFilterColumns([]);
setIsSubTable(false);
setTargetColumn("");
}}
>
<SelectTrigger className="h-7 text-xs">
@@ -552,109 +345,62 @@ function FilterConnectionForm({
</Select>
</div>
{targetMeta && (
<div className="space-y-1">
<span className="text-[10px] text-muted-foreground"> </span>
<Select value={selectedTargetInput} onValueChange={setSelectedTargetInput}>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{targetMeta.receivable.map((r) => (
<SelectItem key={r.key} value={r.key} className="text-xs">
{r.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{selectedTargetInput && !isEventTypeConnection(meta, selectedOutput, targetMeta, selectedTargetInput) && (
<div className="space-y-2 rounded bg-muted p-2">
<p className="text-[10px] font-medium text-muted-foreground"> </p>
{dbColumnsLoading ? (
<div className="flex items-center gap-2 py-2">
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
<span className="text-[10px] text-muted-foreground"> ...</span>
</div>
) : hasAnyColumns ? (
<div className="space-y-2">
{displayColumns.length > 0 && (
<div className="space-y-1">
<p className="text-[9px] font-medium text-emerald-600"> </p>
{displayColumns.map((col) => (
<div key={col} className="flex items-center gap-2">
<Checkbox
id={`col-${col}-${initial?.id || "new"}`}
checked={filterColumns.includes(col)}
onCheckedChange={() => toggleColumn(col)}
/>
<label
htmlFor={`col-${col}-${initial?.id || "new"}`}
className="cursor-pointer text-xs"
>
{col}
</label>
</div>
))}
</div>
)}
{dataOnlyColumns.length > 0 && (
<div className="space-y-1">
{displayColumns.length > 0 && (
<div className="my-1 h-px bg-muted/80" />
)}
<p className="text-[9px] font-medium text-amber-600"> </p>
{dataOnlyColumns.map((col) => (
<div key={col} className="flex items-center gap-2">
<Checkbox
id={`col-${col}-${initial?.id || "new"}`}
checked={filterColumns.includes(col)}
onCheckedChange={() => toggleColumn(col)}
/>
<label
htmlFor={`col-${col}-${initial?.id || "new"}`}
className="cursor-pointer text-xs text-muted-foreground"
>
{col}
</label>
</div>
))}
</div>
)}
</div>
) : (
<Input
value={filterColumns[0] || ""}
onChange={(e) => setFilterColumns(e.target.value ? [e.target.value] : [])}
placeholder="컬럼명 입력"
className="h-7 text-xs"
{isFilterConnection && selectedTargetId && subTableName && (
<div className="space-y-2 rounded bg-muted/50 p-2">
<div className="flex items-center gap-2">
<Checkbox
id={`isSubTable_${component.id}`}
checked={isSubTable}
onCheckedChange={(v) => {
setIsSubTable(v === true);
if (!v) setTargetColumn("");
}}
/>
)}
{filterColumns.length > 0 && (
<p className="text-[10px] text-primary">
{filterColumns.length}
</p>
)}
<div className="space-y-1">
<p className="text-[10px] text-muted-foreground"> </p>
<Select value={filterMode} onValueChange={(v: any) => setFilterMode(v)}>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="contains" className="text-xs"></SelectItem>
<SelectItem value="equals" className="text-xs"></SelectItem>
<SelectItem value="starts_with" className="text-xs"></SelectItem>
<SelectItem value="range" className="text-xs"></SelectItem>
</SelectContent>
</Select>
<label htmlFor={`isSubTable_${component.id}`} className="text-[10px] text-muted-foreground cursor-pointer">
({subTableName})
</label>
</div>
{isSubTable && (
<div className="space-y-2 pl-5">
<div className="space-y-1">
<span className="text-[10px] text-muted-foreground"> </span>
{loadingColumns ? (
<div className="flex items-center gap-1 py-1">
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
<span className="text-[10px] text-muted-foreground"> ...</span>
</div>
) : (
<Select value={targetColumn} onValueChange={setTargetColumn}>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{subColumns.filter(Boolean).map((col) => (
<SelectItem key={col} value={col} className="text-xs">
{col}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
<div className="space-y-1">
<span className="text-[10px] text-muted-foreground"> </span>
<Select value={filterMode} onValueChange={setFilterMode}>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="equals" className="text-xs"> (equals)</SelectItem>
<SelectItem value="contains" className="text-xs"> (contains)</SelectItem>
<SelectItem value="starts_with" className="text-xs"> (starts_with)</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}
</div>
)}
@@ -662,7 +408,7 @@ function FilterConnectionForm({
size="sm"
variant="outline"
className="h-7 w-full text-xs"
disabled={!selectedOutput || !selectedTargetId || !selectedTargetInput}
disabled={!selectedTargetId}
onClick={handleSubmit}
>
{!initial && <Plus className="mr-1 h-3 w-3" />}
@@ -722,32 +468,3 @@ function ReceiveSection({
);
}
// ========================================
// 유틸
// ========================================
function isEventTypeConnection(
sourceMeta: ComponentConnectionMeta | undefined,
outputKey: string,
targetMeta: ComponentConnectionMeta | null | undefined,
inputKey: string,
): boolean {
const sourceItem = sourceMeta?.sendable?.find((s) => s.key === outputKey);
const targetItem = targetMeta?.receivable?.find((r) => r.key === inputKey);
return sourceItem?.type === "event" || targetItem?.type === "event";
}
function buildConnectionLabel(
source: PopComponentDefinitionV5,
_outputKey: string,
target: PopComponentDefinitionV5 | undefined,
_inputKey: string,
columns?: string[]
): string {
const srcLabel = source.label || source.id;
const tgtLabel = target?.label || target?.id || "?";
const colInfo = columns && columns.length > 0
? ` [${columns.join(", ")}]`
: "";
return `${srcLabel}${tgtLabel}${colInfo}`;
}
@@ -72,10 +72,14 @@ const COMPONENT_TYPE_LABELS: Record<PopComponentType, string> = {
"pop-icon": "아이콘",
"pop-dashboard": "대시보드",
"pop-card-list": "카드 목록",
"pop-card-list-v2": "카드 목록 V2",
"pop-button": "버튼",
"pop-string-list": "리스트 목록",
"pop-search": "검색",
"pop-status-bar": "상태 바",
"pop-field": "입력",
"pop-scanner": "스캐너",
"pop-profile": "프로필",
};
// ========================================
@@ -554,7 +558,7 @@ function ComponentContent({ component, effectivePosition, isDesignMode, isSelect
if (ActualComp) {
// 아이콘 컴포넌트는 클릭 이벤트가 필요하므로 pointer-events 허용
// CardList 컴포넌트도 버튼 클릭이 필요하므로 pointer-events 허용
const needsPointerEvents = component.type === "pop-icon" || component.type === "pop-card-list";
const needsPointerEvents = component.type === "pop-icon" || component.type === "pop-card-list" || component.type === "pop-card-list-v2";
return (
<div className={cn(
@@ -9,7 +9,7 @@
/**
* POP 컴포넌트 타입
*/
export type PopComponentType = "pop-sample" | "pop-text" | "pop-icon" | "pop-dashboard" | "pop-card-list" | "pop-button" | "pop-string-list" | "pop-search" | "pop-field";
export type PopComponentType = "pop-sample" | "pop-text" | "pop-icon" | "pop-dashboard" | "pop-card-list" | "pop-card-list-v2" | "pop-button" | "pop-string-list" | "pop-search" | "pop-status-bar" | "pop-field" | "pop-scanner" | "pop-profile";
/**
* 데이터 흐름 정의
@@ -33,6 +33,7 @@ export interface PopDataConnection {
targetColumn: string;
targetColumns?: string[];
filterMode: "equals" | "contains" | "starts_with" | "range";
isSubTable?: boolean;
};
label?: string;
}
@@ -358,10 +359,14 @@ export const DEFAULT_COMPONENT_GRID_SIZE: Record<PopComponentType, { colSpan: nu
"pop-icon": { colSpan: 1, rowSpan: 2 },
"pop-dashboard": { colSpan: 6, rowSpan: 3 },
"pop-card-list": { colSpan: 4, rowSpan: 3 },
"pop-card-list-v2": { colSpan: 4, rowSpan: 3 },
"pop-button": { colSpan: 2, rowSpan: 1 },
"pop-string-list": { colSpan: 4, rowSpan: 3 },
"pop-search": { colSpan: 4, rowSpan: 2 },
"pop-search": { colSpan: 2, rowSpan: 1 },
"pop-status-bar": { colSpan: 6, rowSpan: 1 },
"pop-field": { colSpan: 6, rowSpan: 2 },
"pop-scanner": { colSpan: 1, rowSpan: 1 },
"pop-profile": { colSpan: 1, rowSpan: 1 },
};
/**
@@ -605,6 +605,23 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
}
}, [relatedButtonFilter]);
// TableOptionsContext 필터 변경 시 데이터 재조회 (TableSearchWidget 연동)
const filtersAppliedRef = useRef(false);
useEffect(() => {
// 초기 렌더 시 빈 배열은 무시 (불필요한 재조회 방지)
if (!filtersAppliedRef.current && filters.length === 0) return;
filtersAppliedRef.current = true;
const filterSearchParams: Record<string, any> = {};
filters.forEach((f) => {
if (f.value !== "" && f.value !== undefined && f.value !== null) {
filterSearchParams[f.columnName] = f.value;
}
});
loadData(1, { ...searchValues, ...filterSearchParams });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filters]);
// 카테고리 타입 컬럼의 값 매핑 로드
useEffect(() => {
const loadCategoryMappings = async () => {
@@ -541,8 +541,31 @@ export function DataFilterConfigPanel({
{/* 카테고리 타입이고 값 타입이 category인 경우 셀렉트박스 */}
{filter.valueType === "category" && categoryValues[filter.columnName] ? (
<Select
value={Array.isArray(filter.value) ? filter.value[0] : filter.value}
onValueChange={(value) => handleFilterChange(filter.id, "value", value)}
value={
filter.operator === "in" || filter.operator === "not_in"
? Array.isArray(filter.value) && filter.value.length > 0
? filter.value[0]
: ""
: Array.isArray(filter.value)
? filter.value[0]
: filter.value
}
onValueChange={(selectedValue) => {
if (filter.operator === "in" || filter.operator === "not_in") {
const currentValues = Array.isArray(filter.value) ? filter.value : [];
if (currentValues.includes(selectedValue)) {
handleFilterChange(
filter.id,
"value",
currentValues.filter((v) => v !== selectedValue),
);
} else {
handleFilterChange(filter.id, "value", [...currentValues, selectedValue]);
}
} else {
handleFilterChange(filter.id, "value", selectedValue);
}
}}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue
@@ -109,9 +109,8 @@ export const TableOptionsToolbar: React.FC = () => {
onOpenChange={setColumnPanelOpen}
/>
<FilterPanel
tableId={selectedTableId}
open={filterPanelOpen}
onOpenChange={setFilterPanelOpen}
isOpen={filterPanelOpen}
onClose={() => setFilterPanelOpen(false)}
/>
<GroupingPanel
tableId={selectedTableId}
@@ -288,6 +288,7 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
const [parentValue, setParentValue] = useState<CategoryValue | null>(null);
const [continuousAdd, setContinuousAdd] = useState(false);
const [editingValue, setEditingValue] = useState<CategoryValue | null>(null);
const [deletingValue, setDeletingValue] = useState<CategoryValue | null>(null);
@@ -512,21 +513,24 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
const response = await createCategoryValue(input);
if (response.success) {
toast.success("카테고리가 추가되었습니다");
// 폼 초기화 (모달은 닫지 않고 연속 입력)
setFormData((prev) => ({
...prev,
valueCode: "",
valueLabel: "",
description: "",
color: "",
}));
setTimeout(() => addNameRef.current?.focus(), 50);
// 기존 펼침 상태 유지하면서 데이터 새로고침
await loadTree(true);
// 부모 노드만 펼치기 (하위 추가 시)
if (parentValue) {
setExpandedNodes((prev) => new Set([...prev, parentValue.valueId]));
}
if (continuousAdd) {
setFormData((prev) => ({
...prev,
valueCode: "",
valueLabel: "",
description: "",
color: "",
}));
setTimeout(() => addNameRef.current?.focus(), 50);
} else {
setFormData({ valueCode: "", valueLabel: "", description: "", color: "", isActive: true });
setIsAddModalOpen(false);
}
} else {
toast.error(response.error || "추가 실패");
}
@@ -818,6 +822,19 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
</Button>
</DialogFooter>
<div className="border-t px-4 py-3">
<div className="flex items-center gap-2">
<Checkbox
id="tree-continuous-add"
checked={continuousAdd}
onCheckedChange={(checked) => setContinuousAdd(checked as boolean)}
/>
<Label htmlFor="tree-continuous-add" className="cursor-pointer text-sm font-normal select-none">
( )
</Label>
</div>
</div>
</DialogContent>
</Dialog>
@@ -629,7 +629,7 @@ export const UnifiedSelect = forwardRef<HTMLDivElement, UnifiedSelectProps>((pro
): SelectOption[] => {
const result: SelectOption[] = [];
for (const item of items) {
const prefix = depth > 0 ? " ".repeat(depth) + "└ " : "";
const prefix = depth > 0 ? "\u00A0\u00A0\u00A0".repeat(depth) + "└ " : "";
result.push({
value: String(item.valueId), // valueId를 value로 사용 (채번 매핑과 일치)
label: prefix + item.valueLabel,
+5 -5
View File
@@ -909,10 +909,10 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
const templateSuffix = templateParts.length > 1 ? templateParts.slice(1).join("") : "";
return (
<div className="flex h-full items-center rounded-md border">
<div className="numbering-segment border-input flex h-full items-center rounded-md border outline-none transition-[color,box-shadow]">
{/* 고정 접두어 */}
{templatePrefix && (
<span className="text-muted-foreground bg-muted flex h-full items-center px-2 text-sm">
<span className="text-muted-foreground bg-muted flex h-full items-center rounded-l-[5px] px-2 text-sm">
{templatePrefix}
</span>
)}
@@ -945,13 +945,13 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
}
}}
placeholder="입력"
className="h-full min-w-[60px] flex-1 bg-transparent px-2 text-sm focus-visible:outline-none"
className="h-full min-w-[60px] flex-1 border-0 bg-transparent px-2 text-sm ring-0"
disabled={disabled || isGeneratingNumbering}
style={inputTextStyle}
style={{ ...inputTextStyle, outline: 'none' }}
/>
{/* 고정 접미어 */}
{templateSuffix && (
<span className="text-muted-foreground bg-muted flex h-full items-center px-2 text-sm">
<span className="text-muted-foreground bg-muted flex h-full items-center rounded-r-[5px] px-2 text-sm">
{templateSuffix}
</span>
)}
+1 -1
View File
@@ -901,7 +901,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>((props, ref) =
): SelectOption[] => {
const result: SelectOption[] = [];
for (const item of items) {
const prefix = depth > 0 ? " ".repeat(depth) + "└ " : "";
const prefix = depth > 0 ? "\u00A0\u00A0\u00A0".repeat(depth) + "└ " : "";
result.push({
value: item.valueCode, // 🔧 valueCode를 value로 사용
label: prefix + item.valueLabel,
@@ -375,12 +375,15 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
// Entity 조인 컬럼 토글 (추가/제거)
const toggleEntityJoinColumn = useCallback(
(joinTableName: string, sourceColumn: string, refColumnName: string, refColumnLabel: string, displayField: string) => {
(joinTableName: string, sourceColumn: string, refColumnName: string, refColumnLabel: string, displayField: string, columnType?: string) => {
const currentJoins = config.entityJoins || [];
const existingJoinIdx = currentJoins.findIndex(
(j) => j.sourceColumn === sourceColumn && j.referenceTable === joinTableName,
);
let newEntityJoins = [...currentJoins];
let newColumns = [...config.columns];
if (existingJoinIdx >= 0) {
const existingJoin = currentJoins[existingJoinIdx];
const existingColIdx = existingJoin.columns.findIndex((c) => c.referenceField === refColumnName);
@@ -388,34 +391,49 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
if (existingColIdx >= 0) {
const updatedColumns = existingJoin.columns.filter((_, i) => i !== existingColIdx);
if (updatedColumns.length === 0) {
updateConfig({ entityJoins: currentJoins.filter((_, i) => i !== existingJoinIdx) });
newEntityJoins = newEntityJoins.filter((_, i) => i !== existingJoinIdx);
} else {
const updated = [...currentJoins];
updated[existingJoinIdx] = { ...existingJoin, columns: updatedColumns };
updateConfig({ entityJoins: updated });
newEntityJoins[existingJoinIdx] = { ...existingJoin, columns: updatedColumns };
}
// config.columns에서도 제거
newColumns = newColumns.filter(c => !(c.key === displayField && c.isJoinColumn));
} else {
const updated = [...currentJoins];
updated[existingJoinIdx] = {
newEntityJoins[existingJoinIdx] = {
...existingJoin,
columns: [...existingJoin.columns, { referenceField: refColumnName, displayField }],
};
updateConfig({ entityJoins: updated });
// config.columns에 추가
newColumns.push({
key: displayField,
title: refColumnLabel,
width: "auto",
visible: true,
editable: false,
isJoinColumn: true,
inputType: columnType || "text",
});
}
} else {
updateConfig({
entityJoins: [
...currentJoins,
{
sourceColumn,
referenceTable: joinTableName,
columns: [{ referenceField: refColumnName, displayField }],
},
],
newEntityJoins.push({
sourceColumn,
referenceTable: joinTableName,
columns: [{ referenceField: refColumnName, displayField }],
});
// config.columns에 추가
newColumns.push({
key: displayField,
title: refColumnLabel,
width: "auto",
visible: true,
editable: false,
isJoinColumn: true,
inputType: columnType || "text",
});
}
updateConfig({ entityJoins: newEntityJoins, columns: newColumns });
},
[config.entityJoins, updateConfig],
[config.entityJoins, config.columns, updateConfig],
);
// Entity 조인에 특정 컬럼이 설정되어 있는지 확인
@@ -604,9 +622,9 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
// 컬럼 토글 (현재 테이블 컬럼 - 입력용)
const toggleInputColumn = (column: ColumnOption) => {
const existingIndex = config.columns.findIndex((c) => c.key === column.columnName);
const existingIndex = config.columns.findIndex((c) => c.key === column.columnName && !c.isJoinColumn && !c.isSourceDisplay);
if (existingIndex >= 0) {
const newColumns = config.columns.filter((c) => c.key !== column.columnName);
const newColumns = config.columns.filter((_, i) => i !== existingIndex);
updateConfig({ columns: newColumns });
} else {
// 컬럼의 inputType과 detailSettings 정보 포함
@@ -651,7 +669,7 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
};
const isColumnAdded = (columnName: string) => {
return config.columns.some((c) => c.key === columnName && !c.isSourceDisplay);
return config.columns.some((c) => c.key === columnName && !c.isSourceDisplay && !c.isJoinColumn);
};
const isSourceColumnSelected = (columnName: string) => {
@@ -761,10 +779,9 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
return (
<div className="space-y-4">
<Tabs defaultValue="basic" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="basic" className="text-xs"></TabsTrigger>
<TabsTrigger value="columns" className="text-xs"></TabsTrigger>
<TabsTrigger value="entityJoin" className="text-xs">Entity </TabsTrigger>
</TabsList>
{/* 기본 설정 탭 */}
@@ -1365,6 +1382,84 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
)}
</div>
{/* ===== 🆕 Entity 조인 컬럼 (표시용) ===== */}
<div className="space-y-2 mt-4">
<div className="flex items-center gap-2">
<Link2 className="h-4 w-4 text-primary" />
<Label className="text-xs font-medium text-primary">Entity ()</Label>
</div>
<p className="text-[10px] text-muted-foreground">
FK .
</p>
{loadingEntityJoins ? (
<p className="text-muted-foreground py-2 text-xs"> ...</p>
) : entityJoinData.joinTables.length === 0 ? (
<p className="text-muted-foreground py-2 text-xs">
{entityJoinTargetTable
? `${entityJoinTargetTable} 테이블에 Entity 조인 가능한 컬럼이 없습니다`
: "저장 테이블을 먼저 설정해주세요"}
</p>
) : (
<div className="space-y-3">
{entityJoinData.joinTables.map((joinTable, tableIndex) => {
const sourceColumn = (joinTable as any).joinConfig?.sourceColumn || "";
return (
<div key={tableIndex} className="space-y-1">
<div className="mb-1 flex items-center gap-2 text-[10px] font-medium text-primary">
<Link2 className="h-3 w-3" />
<span>{joinTable.tableName}</span>
<span className="text-muted-foreground">({sourceColumn})</span>
</div>
<div className="max-h-40 space-y-0.5 overflow-y-auto rounded-md border border-primary/20 bg-primary/10/30 p-2">
{joinTable.availableColumns.map((column, colIndex) => {
const isActive = isEntityJoinColumnActive(
joinTable.tableName,
sourceColumn,
column.columnName,
);
const matchingCol = config.columns.find((c) => c.key === column.columnName && c.isJoinColumn);
const displayField = matchingCol?.key || column.columnName;
return (
<div
key={colIndex}
className={cn(
"flex cursor-pointer items-center gap-2 rounded px-2 py-1 hover:bg-primary/10/50",
isActive && "bg-primary/10",
)}
onClick={() =>
toggleEntityJoinColumn(
joinTable.tableName,
sourceColumn,
column.columnName,
column.columnLabel,
displayField,
column.inputType || column.dataType
)
}
>
<Checkbox
checked={isActive}
className="pointer-events-none h-3.5 w-3.5"
/>
<Link2 className="h-3 w-3 flex-shrink-0 text-primary" />
<span className="truncate text-xs">{column.columnLabel}</span>
<span className="ml-auto text-[10px] text-primary/80">
{column.inputType || column.dataType}
</span>
</div>
);
})}
</div>
</div>
);
})}
</div>
)}
</div>
{/* 선택된 컬럼 상세 설정 - 🆕 모든 컬럼 통합, 순서 변경 가능 */}
{config.columns.length > 0 && (
<>
@@ -1381,7 +1476,7 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
<div
className={cn(
"flex items-center gap-2 rounded-md border p-2",
col.isSourceDisplay ? "border-primary/20 bg-primary/10/50" : "border-border bg-muted/30",
(col.isSourceDisplay || col.isJoinColumn) ? "border-primary/20 bg-primary/10/50" : "border-border bg-muted/30",
col.hidden && "opacity-50",
)}
draggable
@@ -1403,7 +1498,7 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
<GripVertical className="text-muted-foreground h-3 w-3 cursor-grab flex-shrink-0" />
{/* 확장/축소 버튼 (입력 컬럼만) */}
{!col.isSourceDisplay && (
{(!col.isSourceDisplay && !col.isJoinColumn) && (
<button
type="button"
onClick={() => setExpandedColumn(expandedColumn === col.key ? null : col.key)}
@@ -1419,8 +1514,10 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
{col.isSourceDisplay ? (
<Link2 className="text-primary h-3 w-3 flex-shrink-0" title="소스 표시 (읽기 전용)" />
) : col.isJoinColumn ? (
<Link2 className="text-amber-500 h-3 w-3 flex-shrink-0" title="Entity 조인 (읽기 전용)" />
) : (
<Database className="text-muted-foreground h-3 w-3 flex-shrink-0" />
<Database className="text-muted-foreground h-3 w-3 flex-shrink-0" />
)}
<Input
@@ -1431,7 +1528,7 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
/>
{/* 히든 토글 (입력 컬럼만) */}
{!col.isSourceDisplay && (
{(!col.isSourceDisplay && !col.isJoinColumn) && (
<button
type="button"
onClick={() => updateColumnProp(col.key, "hidden", !col.hidden)}
@@ -1446,12 +1543,12 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
)}
{/* 자동입력 표시 아이콘 */}
{!col.isSourceDisplay && col.autoFill?.type && col.autoFill.type !== "none" && (
{(!col.isSourceDisplay && !col.isJoinColumn) && col.autoFill?.type && col.autoFill.type !== "none" && (
<Wand2 className="h-3 w-3 text-purple-500 flex-shrink-0" title="자동 입력" />
)}
{/* 편집 가능 토글 */}
{!col.isSourceDisplay && (
{(!col.isSourceDisplay && !col.isJoinColumn) && (
<button
type="button"
onClick={() => updateColumnProp(col.key, "editable", !(col.editable ?? true))}
@@ -1474,6 +1571,13 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
onClick={() => {
if (col.isSourceDisplay) {
toggleSourceDisplayColumn({ columnName: col.key, displayName: col.title });
} else if (col.isJoinColumn) {
const newColumns = config.columns.filter(c => c.key !== col.key);
const newEntityJoins = config.entityJoins?.map(join => ({
...join,
columns: join.columns.filter(c => c.displayField !== col.key)
})).filter(join => join.columns.length > 0);
updateConfig({ columns: newColumns, entityJoins: newEntityJoins });
} else {
toggleInputColumn({ columnName: col.key, displayName: col.title });
}
@@ -1485,7 +1589,7 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
</div>
{/* 확장된 상세 설정 (입력 컬럼만) */}
{!col.isSourceDisplay && expandedColumn === col.key && (
{(!col.isSourceDisplay && !col.isJoinColumn) && expandedColumn === col.key && (
<div className="ml-6 space-y-2 rounded-md border border-dashed border-input bg-muted p-2">
{/* 자동 입력 설정 */}
<div className="space-y-1">
@@ -1812,120 +1916,6 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
)}
</TabsContent>
{/* Entity 조인 설정 탭 */}
<TabsContent value="entityJoin" className="mt-4 space-y-4">
<div className="space-y-2">
<div>
<h3 className="text-sm font-semibold">Entity </h3>
<p className="text-muted-foreground text-[10px]">
FK
</p>
</div>
<hr className="border-border" />
{loadingEntityJoins ? (
<p className="text-muted-foreground py-2 text-center text-xs"> ...</p>
) : entityJoinData.joinTables.length === 0 ? (
<div className="rounded-md border border-dashed p-4 text-center">
<p className="text-muted-foreground text-xs">
{entityJoinTargetTable
? `${entityJoinTargetTable} 테이블에 Entity 조인 가능한 컬럼이 없습니다`
: "저장 테이블을 먼저 설정해주세요"}
</p>
</div>
) : (
<div className="space-y-3">
{entityJoinData.joinTables.map((joinTable, tableIndex) => {
const sourceColumn = (joinTable as any).joinConfig?.sourceColumn || "";
return (
<div key={tableIndex} className="space-y-1">
<div className="mb-1 flex items-center gap-2 text-[10px] font-medium text-primary">
<Link2 className="h-3 w-3" />
<span>{joinTable.tableName}</span>
<span className="text-muted-foreground">({sourceColumn})</span>
</div>
<div className="max-h-40 space-y-0.5 overflow-y-auto rounded-md border border-primary/20 bg-primary/10/30 p-2">
{joinTable.availableColumns.map((column, colIndex) => {
const isActive = isEntityJoinColumnActive(
joinTable.tableName,
sourceColumn,
column.columnName,
);
const matchingCol = config.columns.find((c) => c.key === column.columnName);
const displayField = matchingCol?.key || column.columnName;
return (
<div
key={colIndex}
className={cn(
"flex cursor-pointer items-center gap-2 rounded px-2 py-1 hover:bg-primary/10/50",
isActive && "bg-primary/10",
)}
onClick={() =>
toggleEntityJoinColumn(
joinTable.tableName,
sourceColumn,
column.columnName,
column.columnLabel,
displayField,
)
}
>
<Checkbox
checked={isActive}
className="pointer-events-none h-3.5 w-3.5"
/>
<Link2 className="h-3 w-3 flex-shrink-0 text-primary" />
<span className="truncate text-xs">{column.columnLabel}</span>
<span className="ml-auto text-[10px] text-primary/80">
{column.inputType || column.dataType}
</span>
</div>
);
})}
</div>
</div>
);
})}
</div>
)}
{/* 현재 설정된 Entity 조인 목록 */}
{config.entityJoins && config.entityJoins.length > 0 && (
<div className="space-y-2">
<h4 className="text-xs font-medium"> </h4>
<div className="space-y-1">
{config.entityJoins.map((join, idx) => (
<div key={idx} className="flex items-center gap-1 rounded border bg-muted/30 px-2 py-1 text-[10px]">
<Database className="h-3 w-3 text-primary" />
<span className="font-medium">{join.sourceColumn}</span>
<ArrowRight className="h-3 w-3 text-muted-foreground" />
<span>{join.referenceTable}</span>
<span className="text-muted-foreground">
({join.columns.map((c) => c.referenceField).join(", ")})
</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
updateConfig({
entityJoins: config.entityJoins!.filter((_, i) => i !== idx),
});
}}
className="ml-auto h-4 w-4 p-0 text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
</div>
</div>
)}
</div>
</TabsContent>
</Tabs>
</div>
);
+3 -1
View File
@@ -322,7 +322,9 @@ export async function executeTaskList(
}
case "custom-event":
if (task.eventName) {
if (task.flowId) {
await apiClient.post(`/dataflow/node-flows/${task.flowId}/execute`, {});
} else if (task.eventName) {
publish(task.eventName, task.eventPayload ?? {});
}
break;
+58 -19
View File
@@ -20,7 +20,6 @@ import { usePopEvent } from "./usePopEvent";
import type { PopDataConnection } from "@/components/pop/designer/types/pop-layout";
import {
PopComponentRegistry,
type ConnectionMetaItem,
} from "@/lib/registry/PopComponentRegistry";
interface UseConnectionResolverOptions {
@@ -29,14 +28,21 @@ interface UseConnectionResolverOptions {
componentTypes?: Map<string, string>;
}
interface AutoMatchPair {
sourceKey: string;
targetKey: string;
isFilter: boolean;
}
/**
* / connectionMeta에서 .
* 규칙: category="event" key가
* / connectionMeta에서 .
* 1: category="event" key가 ( )
* 2: 소스 type="filter_value" + type="filter_value" ( )
*/
function getAutoMatchPairs(
sourceType: string,
targetType: string
): { sourceKey: string; targetKey: string }[] {
): AutoMatchPair[] {
const sourceDef = PopComponentRegistry.getComponent(sourceType);
const targetDef = PopComponentRegistry.getComponent(targetType);
@@ -44,14 +50,18 @@ function getAutoMatchPairs(
return [];
}
const pairs: { sourceKey: string; targetKey: string }[] = [];
const pairs: AutoMatchPair[] = [];
for (const s of sourceDef.connectionMeta.sendable) {
if (s.category !== "event") continue;
for (const r of targetDef.connectionMeta.receivable) {
if (r.category !== "event") continue;
if (s.key === r.key) {
pairs.push({ sourceKey: s.key, targetKey: r.key });
if (s.category === "event" && r.category === "event" && s.key === r.key) {
pairs.push({ sourceKey: s.key, targetKey: r.key, isFilter: false });
}
if (s.type === "filter_value" && r.type === "filter_value") {
pairs.push({ sourceKey: s.key, targetKey: r.key, isFilter: true });
}
if (s.type === "all_rows" && r.type === "all_rows") {
pairs.push({ sourceKey: s.key, targetKey: r.key, isFilter: false });
}
}
}
@@ -93,10 +103,30 @@ export function useConnectionResolver({
const targetEvent = `__comp_input__${conn.targetComponent}__${pair.targetKey}`;
const unsub = subscribe(sourceEvent, (payload: unknown) => {
publish(targetEvent, {
value: payload,
_connectionId: conn.id,
});
if (pair.isFilter) {
const data = payload as Record<string, unknown> | null;
const fieldName = data?.fieldName as string | undefined;
const filterColumns = data?.filterColumns as string[] | undefined;
const filterMode = (data?.filterMode as string) || "contains";
// conn.filterConfig에 targetColumn이 명시되어 있으면 우선 사용
const effectiveColumn = conn.filterConfig?.targetColumn || fieldName;
const effectiveMode = conn.filterConfig?.filterMode || filterMode;
const baseFilterConfig = effectiveColumn
? { targetColumn: effectiveColumn, targetColumns: conn.filterConfig?.targetColumns || (filterColumns?.length ? filterColumns : [effectiveColumn]), filterMode: effectiveMode }
: conn.filterConfig;
publish(targetEvent, {
value: payload,
filterConfig: conn.filterConfig?.isSubTable
? { ...baseFilterConfig, isSubTable: true }
: baseFilterConfig,
_connectionId: conn.id,
});
} else {
publish(targetEvent, {
value: payload,
_connectionId: conn.id,
});
}
});
unsubscribers.push(unsub);
}
@@ -121,13 +151,22 @@ export function useConnectionResolver({
const unsub = subscribe(sourceEvent, (payload: unknown) => {
const targetEvent = `__comp_input__${conn.targetComponent}__${conn.targetInput || conn.targetField}`;
const enrichedPayload = {
value: payload,
filterConfig: conn.filterConfig,
_connectionId: conn.id,
};
let resolvedFilterConfig = conn.filterConfig;
if (!resolvedFilterConfig) {
const data = payload as Record<string, unknown> | null;
const fieldName = data?.fieldName as string | undefined;
const filterColumns = data?.filterColumns as string[] | undefined;
if (fieldName) {
const filterMode = (data?.filterMode as string) || "contains";
resolvedFilterConfig = { targetColumn: fieldName, targetColumns: filterColumns?.length ? filterColumns : [fieldName], filterMode: filterMode as "equals" | "contains" | "starts_with" | "range" };
}
}
publish(targetEvent, enrichedPayload);
publish(targetEvent, {
value: payload,
filterConfig: resolvedFilterConfig,
_connectionId: conn.id,
});
});
unsubscribers.push(unsub);
}
+33 -11
View File
@@ -20,6 +20,21 @@ export const useLogin = () => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [isPopMode, setIsPopMode] = useState(false);
// localStorage에서 POP 모드 상태 복원
useEffect(() => {
const saved = localStorage.getItem("popLoginMode");
if (saved === "true") setIsPopMode(true);
}, []);
const togglePopMode = useCallback(() => {
setIsPopMode((prev) => {
const next = !prev;
localStorage.setItem("popLoginMode", String(next));
return next;
});
}, []);
/**
*
@@ -141,17 +156,22 @@ export const useLogin = () => {
// 쿠키에도 저장 (미들웨어에서 사용)
document.cookie = `authToken=${result.data.token}; path=/; max-age=86400; SameSite=Lax`;
// 로그인 성공 - 첫 번째 접근 가능한 메뉴로 리다이렉트
const firstMenuPath = result.data?.firstMenuPath;
if (firstMenuPath) {
// 접근 가능한 메뉴가 있으면 해당 메뉴로 이동
console.log("첫 번째 접근 가능한 메뉴로 이동:", firstMenuPath);
router.push(firstMenuPath);
if (isPopMode) {
const popPath = result.data?.popLandingPath;
if (popPath) {
router.push(popPath);
} else {
setError("POP 화면이 설정되어 있지 않습니다. 관리자에게 메뉴 관리에서 POP 화면을 설정해달라고 요청하세요.");
setIsLoading(false);
return;
}
} else {
// 접근 가능한 메뉴가 없으면 메인 페이지로 이동
console.log("접근 가능한 메뉴가 없어 메인 페이지로 이동");
router.push(AUTH_CONFIG.ROUTES.MAIN);
const firstMenuPath = result.data?.firstMenuPath;
if (firstMenuPath) {
router.push(firstMenuPath);
} else {
router.push(AUTH_CONFIG.ROUTES.MAIN);
}
}
} else {
// 로그인 실패
@@ -165,7 +185,7 @@ export const useLogin = () => {
setIsLoading(false);
}
},
[formData, validateForm, apiCall, router],
[formData, validateForm, apiCall, router, isPopMode],
);
// 컴포넌트 마운트 시 기존 인증 상태 확인
@@ -179,10 +199,12 @@ export const useLogin = () => {
isLoading,
error,
showPassword,
isPopMode,
// 액션
handleInputChange,
handleLogin,
togglePasswordVisibility,
togglePopMode,
};
};
+23
View File
@@ -81,6 +81,23 @@ export interface ApiResponse<T> {
errorCode?: string;
}
export interface PopMenuItem {
objid: string;
menu_name_kor: string;
menu_url: string;
menu_desc: string;
seq: number;
company_code: string;
status: string;
screenId?: number;
}
export interface PopMenuResponse {
parentMenu: PopMenuItem | null;
childMenus: PopMenuItem[];
landingMenu: PopMenuItem | null;
}
export const menuApi = {
// 관리자 메뉴 목록 조회 (좌측 사이드바용 - active만 표시)
getAdminMenus: async (): Promise<ApiResponse<MenuItem[]>> => {
@@ -96,6 +113,12 @@ export const menuApi = {
return response.data;
},
// POP 메뉴 목록 조회 ([POP] 태그 L1 하위 active 메뉴)
getPopMenus: async (): Promise<ApiResponse<PopMenuResponse>> => {
const response = await apiClient.get("/admin/pop-menus");
return response.data;
},
// 관리자 메뉴 목록 조회 (메뉴 관리 화면용 - 모든 상태 표시)
getAdminMenusForManagement: async (): Promise<ApiResponse<MenuItem[]>> => {
const response = await apiClient.get("/admin/menus", { params: { menuType: "0", includeInactive: "true" } });
+23 -7
View File
@@ -98,6 +98,16 @@ export function useDialogAutoValidation(contentEl: HTMLElement | null) {
return el instanceof HTMLSelectElement && el.hasAttribute("aria-hidden");
}
// 복합 입력 필드(채번 세그먼트 등)의 시각적 테두리 컨테이너 탐지
// input 자체에 border가 없고 부모가 border를 가진 경우 부모를 반환
function findBorderContainer(input: TargetEl): HTMLElement {
const parent = input.parentElement;
if (parent && parent.classList.contains("border")) {
return parent;
}
return input;
}
function isEmpty(input: TargetEl): boolean {
if (input instanceof HTMLButtonElement) {
// Radix Select: data-placeholder 속성이 자식 span에 있으면 미선택 상태
@@ -120,20 +130,24 @@ export function useDialogAutoValidation(contentEl: HTMLElement | null) {
}
function markError(input: TargetEl) {
input.setAttribute(ERROR_ATTR, "true");
const container = findBorderContainer(input);
container.setAttribute(ERROR_ATTR, "true");
errorFields.add(input);
showErrorMsg(input);
}
function clearError(input: TargetEl) {
input.removeAttribute(ERROR_ATTR);
const container = findBorderContainer(input);
container.removeAttribute(ERROR_ATTR);
errorFields.delete(input);
removeErrorMsg(input);
}
// 빈 필수 필드 아래에 경고 문구 삽입 (레이아웃 영향 없는 zero-height wrapper)
// 복합 입력(채번 세그먼트 등)은 border 컨테이너 바깥에 삽입
function showErrorMsg(input: TargetEl) {
if (input.parentElement?.querySelector(`.${MSG_WRAPPER_CLASS}`)) return;
const container = findBorderContainer(input);
if (container.parentElement?.querySelector(`.${MSG_WRAPPER_CLASS}`)) return;
const wrapper = document.createElement("div");
wrapper.className = MSG_WRAPPER_CLASS;
@@ -142,17 +156,19 @@ export function useDialogAutoValidation(contentEl: HTMLElement | null) {
msg.textContent = "필수 입력 항목입니다";
wrapper.appendChild(msg);
input.insertAdjacentElement("afterend", wrapper);
container.insertAdjacentElement("afterend", wrapper);
}
function removeErrorMsg(input: TargetEl) {
const wrapper = input.parentElement?.querySelector(`.${MSG_WRAPPER_CLASS}`);
const container = findBorderContainer(input);
const wrapper = container.parentElement?.querySelector(`.${MSG_WRAPPER_CLASS}`);
if (wrapper) wrapper.remove();
}
function highlightField(input: TargetEl) {
input.setAttribute(HIGHLIGHT_ATTR, "true");
input.addEventListener("animationend", () => input.removeAttribute(HIGHLIGHT_ATTR), { once: true });
const container = findBorderContainer(input);
container.setAttribute(HIGHLIGHT_ATTR, "true");
container.addEventListener("animationend", () => container.removeAttribute(HIGHLIGHT_ATTR), { once: true });
if (input instanceof HTMLButtonElement) {
input.click();
@@ -35,6 +35,7 @@ export interface PopComponentDefinition {
preview?: React.ComponentType<{ config?: any }>; // 디자이너 미리보기용
defaultProps?: Record<string, any>;
connectionMeta?: ComponentConnectionMeta;
getDynamicConnectionMeta?: (config: Record<string, unknown>) => ComponentConnectionMeta;
// POP 전용 속성
touchOptimized?: boolean;
minTouchArea?: number;
@@ -36,7 +36,7 @@ import { Card, CardContent } from "@/components/ui/card";
import { toast } from "sonner";
import { useScreenContextOptional } from "@/contexts/ScreenContext";
import { apiClient } from "@/lib/api/client";
import { getCategoryValues } from "@/lib/api/tableCategoryValue";
import { getCategoryValues, getCategoryLabelsByCodes } from "@/lib/api/tableCategoryValue";
export interface SplitPanelLayout2ComponentProps extends ComponentRendererProps {
// 추가 props
@@ -1354,6 +1354,40 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
loadCategoryLabels();
}, [isDesignMode, config.leftPanel?.tableName, config.rightPanel?.tableName, config.leftPanel?.displayColumns, config.rightPanel?.displayColumns, config.rightPanel?.tabConfig?.tabSourceColumn, config.leftPanel?.tabConfig?.tabSourceColumn]);
// 데이터 로드 후 미해결 카테고리 코드를 batch API로 변환
useEffect(() => {
if (isDesignMode) return;
const allData = [...leftData, ...rightData];
if (allData.length === 0) return;
const unresolvedCodes = new Set<string>();
const checkValue = (v: unknown) => {
if (typeof v === "string" && (v.startsWith("CAT_") || v.startsWith("CATEGORY_"))) {
if (!categoryLabelMap[v]) unresolvedCodes.add(v);
}
};
for (const item of allData) {
for (const val of Object.values(item)) {
if (Array.isArray(val)) {
val.forEach(checkValue);
} else {
checkValue(val);
}
}
}
if (unresolvedCodes.size === 0) return;
const resolveMissingLabels = async () => {
const result = await getCategoryLabelsByCodes(Array.from(unresolvedCodes));
if (result.success && result.data && Object.keys(result.data).length > 0) {
setCategoryLabelMap((prev) => ({ ...prev, ...result.data }));
}
};
resolveMissingLabels();
}, [isDesignMode, leftData, rightData, categoryLabelMap]);
// 컴포넌트 언마운트 시 DataProvider 해제
useEffect(() => {
return () => {
@@ -0,0 +1,203 @@
"use client";
import React, { useMemo } from "react";
import { GripVertical } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/lib/utils";
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
verticalListSortingStrategy,
useSortable,
arrayMove,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { FormatSegment } from "./types";
import { SEGMENT_TYPE_LABELS, buildFormattedString, SAMPLE_VALUES } from "./config";
// 개별 세그먼트 행
interface SortableSegmentRowProps {
segment: FormatSegment;
index: number;
onChange: (index: number, updates: Partial<FormatSegment>) => void;
}
function SortableSegmentRow({ segment, index, onChange }: SortableSegmentRowProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: `${segment.type}-${index}` });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<div
ref={setNodeRef}
style={style}
className={cn(
"grid grid-cols-[16px_56px_18px_1fr_1fr_1fr] items-center gap-1 rounded border bg-white px-2 py-1.5",
isDragging && "opacity-50",
)}
>
<div
{...attributes}
{...listeners}
className="cursor-grab text-gray-400 hover:text-gray-600"
>
<GripVertical className="h-3.5 w-3.5" />
</div>
<span className="truncate text-xs font-medium">
{SEGMENT_TYPE_LABELS[segment.type]}
</span>
<Checkbox
checked={segment.showLabel}
onCheckedChange={(checked) =>
onChange(index, { showLabel: checked === true })
}
className="h-3.5 w-3.5"
/>
<Input
value={segment.label}
onChange={(e) => onChange(index, { label: e.target.value })}
placeholder=""
className={cn(
"h-6 px-1 text-xs",
!segment.showLabel && "text-gray-400 line-through",
)}
/>
<Input
value={segment.separatorAfter}
onChange={(e) => onChange(index, { separatorAfter: e.target.value })}
placeholder=""
className="h-6 px-1 text-center text-xs"
/>
<Input
type="number"
min={0}
max={5}
value={segment.pad}
onChange={(e) =>
onChange(index, { pad: parseInt(e.target.value) || 0 })
}
disabled={segment.type !== "row" && segment.type !== "level"}
className={cn(
"h-6 px-1 text-center text-xs",
segment.type !== "row" && segment.type !== "level" && "bg-gray-100 opacity-50",
)}
/>
</div>
);
}
// FormatSegmentEditor 메인 컴포넌트
interface FormatSegmentEditorProps {
label: string;
segments: FormatSegment[];
onChange: (segments: FormatSegment[]) => void;
sampleValues?: Record<string, string>;
}
export function FormatSegmentEditor({
label,
segments,
onChange,
sampleValues = SAMPLE_VALUES,
}: FormatSegmentEditorProps) {
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 5 },
}),
useSensor(KeyboardSensor),
);
const preview = useMemo(
() => buildFormattedString(segments, sampleValues),
[segments, sampleValues],
);
const sortableIds = useMemo(
() => segments.map((seg, i) => `${seg.type}-${i}`),
[segments],
);
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = sortableIds.indexOf(active.id as string);
const newIndex = sortableIds.indexOf(over.id as string);
if (oldIndex === -1 || newIndex === -1) return;
onChange(arrayMove([...segments], oldIndex, newIndex));
};
const handleSegmentChange = (index: number, updates: Partial<FormatSegment>) => {
const updated = segments.map((seg, i) =>
i === index ? { ...seg, ...updates } : seg,
);
onChange(updated);
};
return (
<div className="space-y-2">
<div className="text-xs font-medium text-gray-600">{label}</div>
<div className="grid grid-cols-[16px_56px_18px_1fr_1fr_1fr] items-center gap-1 px-2 text-[10px] text-gray-500">
<span />
<span />
<span />
<span></span>
<span className="text-center"></span>
<span className="text-center">릿</span>
</div>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext items={sortableIds} strategy={verticalListSortingStrategy}>
<div className="space-y-1">
{segments.map((segment, index) => (
<SortableSegmentRow
key={sortableIds[index]}
segment={segment}
index={index}
onChange={handleSegmentChange}
/>
))}
</div>
</SortableContext>
</DndContext>
<div className="rounded bg-gray-50 px-2 py-1.5">
<span className="text-[10px] text-gray-500">: </span>
<span className="text-xs font-medium text-gray-800">
{preview || "(빈 값)"}
</span>
</div>
</div>
);
}
@@ -20,6 +20,7 @@ import {
GeneratedLocation,
RackStructureContext,
} from "./types";
import { defaultFormatConfig, buildFormattedString } from "./config";
// 기존 위치 데이터 타입
interface ExistingLocation {
@@ -95,12 +96,12 @@ const ConditionCard: React.FC<ConditionCardProps> = ({
};
return (
<div className="relative rounded-lg border border-border bg-white shadow-sm">
<div className="border-border relative rounded-lg border bg-white shadow-sm">
{/* 헤더 */}
<div className="flex items-center justify-between rounded-t-lg bg-primary px-4 py-2 text-white">
<div className="bg-primary flex items-center justify-between rounded-t-lg px-4 py-2 text-white">
<span className="font-medium"> {index + 1}</span>
{!readonly && (
<button onClick={() => onRemove(condition.id)} className="rounded p-1 transition-colors hover:bg-primary/90">
<button onClick={() => onRemove(condition.id)} className="hover:bg-primary/90 rounded p-1 transition-colors">
<X className="h-4 w-4" />
</button>
)}
@@ -111,7 +112,7 @@ const ConditionCard: React.FC<ConditionCardProps> = ({
{/* 열 범위 */}
<div className="flex items-center gap-2">
<div className="flex-1">
<label className="mb-1 block text-xs font-medium text-foreground">
<label className="text-foreground mb-1 block text-xs font-medium">
<span className="text-destructive">*</span>
</label>
<div className="flex items-center gap-2">
@@ -139,7 +140,7 @@ const ConditionCard: React.FC<ConditionCardProps> = ({
</div>
</div>
<div className="w-20">
<label className="mb-1 block text-xs font-medium text-foreground">
<label className="text-foreground mb-1 block text-xs font-medium">
<span className="text-destructive">*</span>
</label>
<Input
@@ -156,7 +157,7 @@ const ConditionCard: React.FC<ConditionCardProps> = ({
</div>
{/* 계산 결과 */}
<div className="rounded-md bg-primary/10 px-3 py-2 text-center text-sm text-primary">
<div className="bg-primary/10 text-primary rounded-md px-3 py-2 text-center text-sm">
{locationCount > 0 ? (
<>
{localValues.startRow} ~ {localValues.endRow} x {localValues.levels} ={" "}
@@ -288,11 +289,10 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
return ctx;
}, [propContext, formData, fieldMapping, getCategoryLabel]);
// 필수 필드 검증
// 필수 필드 검증 (층은 선택 입력)
const missingFields = useMemo(() => {
const missing: string[] = [];
if (!context.warehouseCode) missing.push("창고 코드");
if (!context.floor) missing.push("층");
if (!context.zone) missing.push("구역");
return missing;
}, [context]);
@@ -377,9 +377,8 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
// 기존 데이터 조회 (창고/층/구역이 변경될 때마다)
useEffect(() => {
const loadExistingLocations = async () => {
// 필수 조건이 충족되지 않으면 기존 데이터 초기화
// DB에는 라벨 값(예: "1층", "A구역")으로 저장되어 있으므로 라벨 값 사용
if (!warehouseCodeForQuery || !floorForQuery || !zoneForQuery) {
// 창고 코드와 구역은 필수, 층은 선택
if (!warehouseCodeForQuery || !zoneForQuery) {
setExistingLocations([]);
setDuplicateErrors([]);
return;
@@ -387,14 +386,13 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
setIsCheckingDuplicates(true);
try {
// warehouse_location 테이블에서 해당 창고/층/구역의 기존 데이터 조회
// DB에는 라벨 값으로 저장되어 있으므로 라벨 값으로 필터링
// equals 연산자를 사용하여 정확한 일치 검색 (ILIKE가 아닌 = 연산자 사용)
const searchParams = {
const searchParams: Record<string, any> = {
warehouse_code: { value: warehouseCodeForQuery, operator: "equals" },
floor: { value: floorForQuery, operator: "equals" },
zone: { value: zoneForQuery, operator: "equals" },
};
if (floorForQuery) {
searchParams.floor = { value: floorForQuery, operator: "equals" };
}
// 직접 apiClient 사용하여 정확한 형식으로 요청
// 백엔드는 search를 객체로 받아서 각 필드를 WHERE 조건으로 처리
@@ -493,23 +491,26 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
return { totalLocations, totalRows, maxLevel };
}, [conditions]);
// 위치 코드 생성
// 포맷 설정 (ConfigPanel에서 관리자가 설정한 값, 미설정 시 기본값)
const formatConfig = config.formatConfig || defaultFormatConfig;
// 위치 코드 생성 (세그먼트 기반 - 순서/구분자/라벨/자릿수 모두 formatConfig에 따름)
const generateLocationCode = useCallback(
(row: number, level: number): { code: string; name: string } => {
const warehouseCode = context?.warehouseCode || "WH001";
const floor = context?.floor || "1";
const zone = context?.zone || "A";
const values: Record<string, string> = {
warehouseCode: context?.warehouseCode || "WH001",
floor: context?.floor || "",
zone: context?.zone || "A",
row: row.toString(),
level: level.toString(),
};
// 코드 생성 (예: WH001-1층D구역-01-1)
const code = `${warehouseCode}-${floor}${zone}-${row.toString().padStart(2, "0")}-${level}`;
// 이름 생성 - zone에 이미 "구역"이 포함되어 있으면 그대로 사용
const zoneName = zone.includes("구역") ? zone : `${zone}구역`;
const name = `${zoneName}-${row.toString().padStart(2, "0")}열-${level}`;
const code = buildFormattedString(formatConfig.codeSegments, values);
const name = buildFormattedString(formatConfig.nameSegments, values);
return { code, name };
},
[context],
[context, formatConfig],
);
// 미리보기 생성
@@ -626,7 +627,7 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="flex items-center gap-2 text-base">
<div className="h-4 w-1 rounded bg-gradient-to-b from-green-500 to-primary/50" />
<div className="to-primary/50 h-4 w-1 rounded bg-gradient-to-b from-green-500" />
</CardTitle>
{!readonly && (
<div className="flex items-center gap-2">
@@ -719,8 +720,8 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
{/* 기존 데이터 존재 알림 */}
{!isCheckingDuplicates && existingLocations.length > 0 && !hasDuplicateWithExisting && (
<Alert className="mb-4 border-primary/20 bg-primary/10">
<AlertCircle className="h-4 w-4 text-primary" />
<Alert className="border-primary/20 bg-primary/10 mb-4">
<AlertCircle className="text-primary h-4 w-4" />
<AlertDescription className="text-primary">
// <strong>{existingLocations.length}</strong> .
</AlertDescription>
@@ -729,9 +730,9 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
{/* 현재 매핑된 값 표시 */}
{(context.warehouseCode || context.warehouseName || context.floor || context.zone) && (
<div className="mb-4 flex flex-wrap gap-2 rounded-lg bg-muted p-3">
<div className="bg-muted mb-4 flex flex-wrap gap-2 rounded-lg p-3">
{(context.warehouseCode || context.warehouseName) && (
<span className="rounded bg-primary/10 px-2 py-1 text-xs text-primary">
<span className="bg-primary/10 text-primary rounded px-2 py-1 text-xs">
: {context.warehouseName || context.warehouseCode}
{context.warehouseName && context.warehouseCode && ` (${context.warehouseCode})`}
</span>
@@ -748,28 +749,28 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
</span>
)}
{context.status && (
<span className="rounded bg-muted/80 px-2 py-1 text-xs text-foreground">: {context.status}</span>
<span className="bg-muted/80 text-foreground rounded px-2 py-1 text-xs">: {context.status}</span>
)}
</div>
)}
{/* 안내 메시지 */}
<div className="mb-4 rounded-lg bg-primary/10 p-4">
<ol className="space-y-1 text-sm text-primary">
<div className="bg-primary/10 mb-4 rounded-lg p-4">
<ol className="text-primary space-y-1 text-sm">
<li className="flex items-start gap-2">
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded bg-primary text-xs font-bold text-white">
<span className="bg-primary flex h-5 w-5 shrink-0 items-center justify-center rounded text-xs font-bold text-white">
1
</span>
</li>
<li className="flex items-start gap-2">
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded bg-primary text-xs font-bold text-white">
<span className="bg-primary flex h-5 w-5 shrink-0 items-center justify-center rounded text-xs font-bold text-white">
2
</span>
</li>
<li className="flex items-start gap-2">
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded bg-primary text-xs font-bold text-white">
<span className="bg-primary flex h-5 w-5 shrink-0 items-center justify-center rounded text-xs font-bold text-white">
3
</span>
예시: 조건1(1~3, 3), 2(4~6, 5)
@@ -779,9 +780,9 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
{/* 조건 목록 또는 빈 상태 */}
{conditions.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-border py-12">
<div className="mb-4 text-6xl text-muted-foreground/50">📦</div>
<p className="mb-4 text-muted-foreground"> </p>
<div className="border-border flex flex-col items-center justify-center rounded-lg border-2 border-dashed py-12">
<div className="text-muted-foreground/50 mb-4 text-6xl">📦</div>
<p className="text-muted-foreground mb-4"> </p>
{!readonly && (
<Button onClick={addCondition} className="gap-1">
<Plus className="h-4 w-4" />
@@ -832,15 +833,15 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
{config.showStatistics && (
<div className="mb-4 grid grid-cols-3 gap-4">
<div className="rounded-lg border bg-white p-4 text-center">
<div className="text-sm text-muted-foreground"> </div>
<div className="text-muted-foreground text-sm"> </div>
<div className="text-2xl font-bold">{statistics.totalLocations}</div>
</div>
<div className="rounded-lg border bg-white p-4 text-center">
<div className="text-sm text-muted-foreground"> </div>
<div className="text-muted-foreground text-sm"> </div>
<div className="text-2xl font-bold">{statistics.totalRows}</div>
</div>
<div className="rounded-lg border bg-white p-4 text-center">
<div className="text-sm text-muted-foreground"> </div>
<div className="text-muted-foreground text-sm"> </div>
<div className="text-2xl font-bold">{statistics.maxLevel}</div>
</div>
</div>
@@ -851,7 +852,7 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
<div className="rounded-lg border">
<ScrollArea className="h-[400px]">
<Table>
<TableHeader className="sticky top-0 bg-muted">
<TableHeader className="bg-muted sticky top-0">
<TableRow>
<TableHead className="w-12 text-center">No</TableHead>
<TableHead></TableHead>
@@ -870,7 +871,7 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
<TableCell className="text-center">{idx + 1}</TableCell>
<TableCell className="font-mono">{loc.location_code}</TableCell>
<TableCell>{loc.location_name}</TableCell>
<TableCell className="text-center">{loc.floor || context?.floor || "1"}</TableCell>
<TableCell className="text-center">{loc.floor || context?.floor || "-"}</TableCell>
<TableCell className="text-center">{loc.zone || context?.zone || "A"}</TableCell>
<TableCell className="text-center">{loc.row_num.padStart(2, "0")}</TableCell>
<TableCell className="text-center">{loc.level_num}</TableCell>
@@ -883,8 +884,8 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
</ScrollArea>
</div>
) : (
<div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-border py-8 text-muted-foreground">
<Eye className="mb-2 h-8 w-8 text-muted-foreground/50" />
<div className="border-border text-muted-foreground flex flex-col items-center justify-center rounded-lg border-2 border-dashed py-8">
<Eye className="text-muted-foreground/50 mb-2 h-8 w-8" />
<p> </p>
</div>
)}
@@ -931,16 +932,16 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
{/* 템플릿 목록 */}
{templates.length > 0 ? (
<div className="space-y-2">
<div className="text-sm font-medium text-foreground"> 릿</div>
<div className="text-foreground text-sm font-medium"> 릿</div>
<ScrollArea className="h-[200px]">
{templates.map((template) => (
<div
key={template.id}
className="flex items-center justify-between rounded-lg border p-3 hover:bg-muted"
className="hover:bg-muted flex items-center justify-between rounded-lg border p-3"
>
<div>
<div className="font-medium">{template.name}</div>
<div className="text-xs text-muted-foreground">{template.conditions.length} </div>
<div className="text-muted-foreground text-xs">{template.conditions.length} </div>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => loadTemplate(template)}>
@@ -955,7 +956,7 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
</ScrollArea>
</div>
) : (
<div className="py-8 text-center text-muted-foreground"> 릿 </div>
<div className="text-muted-foreground py-8 text-center"> 릿 </div>
)}
</div>
)}
@@ -4,14 +4,10 @@ import React, { useState, useEffect } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { RackStructureComponentConfig, FieldMapping } from "./types";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { RackStructureComponentConfig, FieldMapping, FormatSegment } from "./types";
import { defaultFormatConfig, SAMPLE_VALUES } from "./config";
import { FormatSegmentEditor } from "./FormatSegmentEditor";
interface RackStructureConfigPanelProps {
config: RackStructureComponentConfig;
@@ -34,9 +30,7 @@ export const RackStructureConfigPanel: React.FC<RackStructureConfigPanelProps> =
tables = [],
}) => {
// 사용 가능한 컬럼 목록 추출
const [availableColumns, setAvailableColumns] = useState<
Array<{ value: string; label: string }>
>([]);
const [availableColumns, setAvailableColumns] = useState<Array<{ value: string; label: string }>>([]);
useEffect(() => {
// 모든 테이블의 컬럼을 플랫하게 추출
@@ -69,14 +63,24 @@ export const RackStructureConfigPanel: React.FC<RackStructureConfigPanelProps> =
const fieldMapping = config.fieldMapping || {};
const formatConfig = config.formatConfig || defaultFormatConfig;
const handleFormatChange = (key: "codeSegments" | "nameSegments", segments: FormatSegment[]) => {
onChange({
...config,
formatConfig: {
...formatConfig,
[key]: segments,
},
});
};
return (
<div className="space-y-4">
{/* 필드 매핑 섹션 */}
<div className="space-y-3">
<div className="text-sm font-medium text-foreground"> </div>
<p className="text-xs text-muted-foreground">
</p>
<div className="text-foreground text-sm font-medium"> </div>
<p className="text-muted-foreground text-xs"> </p>
{/* 창고 코드 필드 */}
<div>
@@ -207,7 +211,7 @@ export const RackStructureConfigPanel: React.FC<RackStructureConfigPanelProps> =
{/* 제한 설정 */}
<div className="space-y-3 border-t pt-3">
<div className="text-sm font-medium text-foreground"> </div>
<div className="text-foreground text-sm font-medium"> </div>
<div>
<Label className="text-xs"> </Label>
@@ -248,7 +252,7 @@ export const RackStructureConfigPanel: React.FC<RackStructureConfigPanelProps> =
{/* UI 설정 */}
<div className="space-y-3 border-t pt-3">
<div className="text-sm font-medium text-foreground">UI </div>
<div className="text-foreground text-sm font-medium">UI </div>
<div className="flex items-center justify-between">
<Label className="text-xs">릿 </Label>
@@ -276,12 +280,31 @@ export const RackStructureConfigPanel: React.FC<RackStructureConfigPanelProps> =
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch
checked={config.readonly ?? false}
onCheckedChange={(checked) => handleChange("readonly", checked)}
/>
<Switch checked={config.readonly ?? false} onCheckedChange={(checked) => handleChange("readonly", checked)} />
</div>
</div>
{/* 포맷 설정 */}
<div className="space-y-3 border-t pt-3">
<div className="text-sm font-medium text-gray-700"> </div>
<p className="text-xs text-gray-500">
, /
</p>
<FormatSegmentEditor
label="위치코드 포맷"
segments={formatConfig.codeSegments}
onChange={(segs) => handleFormatChange("codeSegments", segs)}
sampleValues={SAMPLE_VALUES}
/>
<FormatSegmentEditor
label="위치명 포맷"
segments={formatConfig.nameSegments}
onChange={(segs) => handleFormatChange("nameSegments", segs)}
sampleValues={SAMPLE_VALUES}
/>
</div>
</div>
);
};
@@ -2,26 +2,107 @@
*
*/
import { RackStructureComponentConfig } from "./types";
import {
RackStructureComponentConfig,
FormatSegment,
FormatSegmentType,
LocationFormatConfig,
} from "./types";
// 세그먼트 타입별 한글 표시명
export const SEGMENT_TYPE_LABELS: Record<FormatSegmentType, string> = {
warehouseCode: "창고코드",
floor: "층",
zone: "구역",
row: "열",
level: "단",
};
// 위치코드 기본 세그먼트 (현재 하드코딩과 동일한 결과)
export const defaultCodeSegments: FormatSegment[] = [
{ type: "warehouseCode", enabled: true, showLabel: false, label: "", separatorAfter: "-", pad: 0 },
{ type: "floor", enabled: true, showLabel: true, label: "층", separatorAfter: "", pad: 0 },
{ type: "zone", enabled: true, showLabel: true, label: "구역", separatorAfter: "-", pad: 0 },
{ type: "row", enabled: true, showLabel: false, label: "", separatorAfter: "-", pad: 2 },
{ type: "level", enabled: true, showLabel: false, label: "", separatorAfter: "", pad: 0 },
];
// 위치명 기본 세그먼트 (현재 하드코딩과 동일한 결과)
export const defaultNameSegments: FormatSegment[] = [
{ type: "zone", enabled: true, showLabel: true, label: "구역", separatorAfter: "-", pad: 0 },
{ type: "row", enabled: true, showLabel: true, label: "열", separatorAfter: "-", pad: 2 },
{ type: "level", enabled: true, showLabel: true, label: "단", separatorAfter: "", pad: 0 },
];
export const defaultFormatConfig: LocationFormatConfig = {
codeSegments: defaultCodeSegments,
nameSegments: defaultNameSegments,
};
// 세그먼트 타입별 기본 한글 접미사 (context 값에 포함되어 있는 한글)
const KNOWN_SUFFIXES: Partial<Record<FormatSegmentType, string>> = {
floor: "층",
zone: "구역",
};
// 값에서 알려진 한글 접미사를 제거하여 순수 값만 추출
function stripKnownSuffix(type: FormatSegmentType, val: string): string {
const suffix = KNOWN_SUFFIXES[type];
if (suffix && val.endsWith(suffix)) {
return val.slice(0, -suffix.length);
}
return val;
}
// 세그먼트 배열로 포맷된 문자열 생성
export function buildFormattedString(
segments: FormatSegment[],
values: Record<string, string>,
): string {
const activeSegments = segments.filter(
(seg) => seg.enabled && values[seg.type],
);
return activeSegments
.map((seg, idx) => {
// 1) 원본 값에서 한글 접미사를 먼저 벗겨냄 ("A구역" → "A", "1층" → "1")
let val = stripKnownSuffix(seg.type, values[seg.type]);
// 2) showLabel이 켜져 있고 label이 있으면 붙임
if (seg.showLabel && seg.label) {
val += seg.label;
}
if (seg.pad > 0 && !isNaN(Number(val))) {
val = val.padStart(seg.pad, "0");
}
if (idx < activeSegments.length - 1) {
val += seg.separatorAfter;
}
return val;
})
.join("");
}
// 미리보기용 샘플 값
export const SAMPLE_VALUES: Record<string, string> = {
warehouseCode: "WH001",
floor: "1층",
zone: "A구역",
row: "1",
level: "1",
};
export const defaultConfig: RackStructureComponentConfig = {
// 기본 제한
maxConditions: 10,
maxRows: 99,
maxLevels: 20,
// 기본 코드 패턴
codePattern: "{warehouseCode}-{floor}{zone}-{row:02d}-{level}",
namePattern: "{zone}구역-{row:02d}열-{level}단",
// UI 설정
showTemplates: true,
showPreview: true,
showStatistics: true,
readonly: false,
// 초기 조건 없음
initialConditions: [],
};
@@ -43,6 +43,24 @@ export interface FieldMapping {
statusField?: string; // 사용 여부로 사용할 폼 필드명
}
// 포맷 세그먼트 (위치코드/위치명의 각 구성요소)
export type FormatSegmentType = 'warehouseCode' | 'floor' | 'zone' | 'row' | 'level';
export interface FormatSegment {
type: FormatSegmentType;
enabled: boolean; // 이 세그먼트를 포함할지 여부
showLabel: boolean; // 한글 라벨 표시 여부 (false면 값에서 라벨 제거)
label: string; // 한글 라벨 (예: "층", "구역", "열", "단")
separatorAfter: string; // 이 세그먼트 뒤의 구분자 (예: "-", "/", "")
pad: number; // 최소 자릿수 (0 = 그대로, 2 = "01"처럼 2자리 맞춤)
}
// 위치코드 + 위치명 포맷 설정
export interface LocationFormatConfig {
codeSegments: FormatSegment[];
nameSegments: FormatSegment[];
}
// 컴포넌트 설정
export interface RackStructureComponentConfig {
// 기본 설정
@@ -54,8 +72,9 @@ export interface RackStructureComponentConfig {
fieldMapping?: FieldMapping;
// 위치 코드 생성 규칙
codePattern?: string; // 코드 패턴 (예: "{warehouse}-{floor}{zone}-{row:02d}-{level}")
namePattern?: string; // 이름 패턴 (예: "{zone}구역-{row:02d}열-{level}단")
codePattern?: string; // 코드 패턴 (하위 호환용 유지)
namePattern?: string; // 이름 패턴 (하위 호환용 유지)
formatConfig?: LocationFormatConfig; // 구조화된 포맷 설정
// UI 설정
showTemplates?: boolean; // 템플릿 기능 표시
@@ -93,5 +112,3 @@ export interface RackStructureComponentProps {
isPreview?: boolean;
tableName?: string;
}
@@ -1098,23 +1098,24 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
return formatNumberValue(value, format);
}
// 🆕 카테고리 매핑 찾기 (여러 키 형태 시도)
// 카테고리 매핑 찾기 (여러 키 형태 시도)
// 1. 전체 컬럼명 (예: "item_info.material")
// 2. 컬럼명만 (예: "material")
// 3. 전역 폴백: 모든 매핑에서 value 검색
let mapping = categoryMappings[columnName];
if (!mapping && columnName.includes(".")) {
// 조인된 컬럼의 경우 컬럼명만으로 다시 시도
const simpleColumnName = columnName.split(".").pop() || columnName;
mapping = categoryMappings[simpleColumnName];
}
if (mapping && mapping[String(value)]) {
const categoryData = mapping[String(value)];
const displayLabel = categoryData.label || String(value);
const strValue = String(value);
if (mapping && mapping[strValue]) {
const categoryData = mapping[strValue];
const displayLabel = categoryData.label || strValue;
const displayColor = categoryData.color || "#64748b";
// 배지로 표시
return (
<Badge
style={{
@@ -1128,6 +1129,29 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
);
}
// 전역 폴백: 컬럼명으로 매핑을 못 찾았을 때, 전체 매핑에서 값 검색
if (!mapping && (strValue.startsWith("CAT_") || strValue.startsWith("CATEGORY_"))) {
for (const key of Object.keys(categoryMappings)) {
const m = categoryMappings[key];
if (m && m[strValue]) {
const categoryData = m[strValue];
const displayLabel = categoryData.label || strValue;
const displayColor = categoryData.color || "#64748b";
return (
<Badge
style={{
backgroundColor: displayColor,
borderColor: displayColor,
}}
className="text-white"
>
{displayLabel}
</Badge>
);
}
}
}
// 🆕 자동 날짜 감지 (ISO 8601 형식 또는 Date 객체)
if (typeof value === "string" && value.match(/^\d{4}-\d{2}-\d{2}(T|\s)/)) {
return formatDateValue(value, "YYYY-MM-DD");
@@ -1247,10 +1271,44 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
console.log("🔗 [분할패널] API 응답 첫 번째 데이터:", result.data[0]);
}
// 좌측 패널 dataFilter 클라이언트 사이드 적용
let filteredLeftData = result.data || [];
const leftDataFilter = componentConfig.leftPanel?.dataFilter;
if (leftDataFilter?.enabled && leftDataFilter.filters?.length > 0) {
const matchFn = leftDataFilter.matchType === "any" ? "some" : "every";
filteredLeftData = filteredLeftData.filter((item: any) => {
return leftDataFilter.filters[matchFn]((cond: any) => {
const val = item[cond.columnName];
switch (cond.operator) {
case "equals":
return val === cond.value;
case "not_equals":
return val !== cond.value;
case "in": {
const arr = Array.isArray(cond.value) ? cond.value : [cond.value];
return arr.includes(val);
}
case "not_in": {
const arr = Array.isArray(cond.value) ? cond.value : [cond.value];
return !arr.includes(val);
}
case "contains":
return String(val || "").includes(String(cond.value));
case "is_null":
return val === null || val === undefined || val === "";
case "is_not_null":
return val !== null && val !== undefined && val !== "";
default:
return true;
}
});
});
}
// 가나다순 정렬 (좌측 패널의 표시 컬럼 기준)
const leftColumn = componentConfig.rightPanel?.relation?.leftColumn;
if (leftColumn && result.data.length > 0) {
result.data.sort((a, b) => {
if (leftColumn && filteredLeftData.length > 0) {
filteredLeftData.sort((a, b) => {
const aValue = String(a[leftColumn] || "");
const bValue = String(b[leftColumn] || "");
return aValue.localeCompare(bValue, "ko-KR");
@@ -1258,7 +1316,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
}
// 계층 구조 빌드
const hierarchicalData = buildHierarchy(result.data);
const hierarchicalData = buildHierarchy(filteredLeftData);
setLeftData(hierarchicalData);
} catch (error) {
console.error("좌측 데이터 로드 실패:", error);
@@ -1317,7 +1375,16 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
case "equals":
return value === cond.value;
case "notEquals":
case "not_equals":
return value !== cond.value;
case "in": {
const arr = Array.isArray(cond.value) ? cond.value : [cond.value];
return arr.includes(value);
}
case "not_in": {
const arr = Array.isArray(cond.value) ? cond.value : [cond.value];
return !arr.includes(value);
}
case "contains":
return String(value || "").includes(String(cond.value));
case "is_null":
@@ -1634,7 +1701,16 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
case "equals":
return value === cond.value;
case "notEquals":
case "not_equals":
return value !== cond.value;
case "in": {
const arr = Array.isArray(cond.value) ? cond.value : [cond.value];
return arr.includes(value);
}
case "not_in": {
const arr = Array.isArray(cond.value) ? cond.value : [cond.value];
return !arr.includes(value);
}
case "contains":
return String(value || "").includes(String(cond.value));
case "is_null":
@@ -2026,43 +2102,59 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
loadRightTableColumns();
}, [componentConfig.rightPanel?.tableName, componentConfig.rightPanel?.additionalTabs, isDesignMode]);
// 좌측 테이블 카테고리 매핑 로드
// 좌측 테이블 카테고리 매핑 로드 (조인된 테이블 포함)
useEffect(() => {
const loadLeftCategoryMappings = async () => {
const leftTableName = componentConfig.leftPanel?.tableName;
if (!leftTableName || isDesignMode) return;
try {
// 1. 컬럼 메타 정보 조회
const columnsResponse = await tableTypeApi.getColumns(leftTableName);
const categoryColumns = columnsResponse.filter((col: any) => col.inputType === "category");
if (categoryColumns.length === 0) {
setLeftCategoryMappings({});
return;
}
// 2. 각 카테고리 컬럼에 대한 값 조회
const mappings: Record<string, Record<string, { label: string; color?: string }>> = {};
const tablesToLoad = new Set<string>([leftTableName]);
for (const col of categoryColumns) {
const columnName = col.columnName || col.column_name;
// 좌측 패널 컬럼 설정에서 조인된 테이블 추출
const leftColumns = componentConfig.leftPanel?.columns || [];
leftColumns.forEach((col: any) => {
const colName = col.name || col.columnName;
if (colName && colName.includes(".")) {
const joinTableName = colName.split(".")[0];
tablesToLoad.add(joinTableName);
}
});
// 각 테이블에 대해 카테고리 매핑 로드
for (const tableName of tablesToLoad) {
try {
const response = await apiClient.get(`/table-categories/${leftTableName}/${columnName}/values?includeInactive=true`);
const columnsResponse = await tableTypeApi.getColumns(tableName);
const categoryColumns = columnsResponse.filter((col: any) => col.inputType === "category");
if (response.data.success && response.data.data) {
const valueMap: Record<string, { label: string; color?: string }> = {};
response.data.data.forEach((item: any) => {
valueMap[item.value_code || item.valueCode] = {
label: item.value_label || item.valueLabel,
color: item.color,
};
});
mappings[columnName] = valueMap;
console.log(`✅ 좌측 카테고리 매핑 로드 [${columnName}]:`, valueMap);
for (const col of categoryColumns) {
const columnName = col.columnName || col.column_name;
try {
const response = await apiClient.get(`/table-categories/${tableName}/${columnName}/values?includeInactive=true`);
if (response.data.success && response.data.data) {
const valueMap: Record<string, { label: string; color?: string }> = {};
response.data.data.forEach((item: any) => {
valueMap[item.value_code || item.valueCode] = {
label: item.value_label || item.valueLabel,
color: item.color,
};
});
// 조인된 테이블은 "테이블명.컬럼명" 형태로도 저장
const mappingKey = tableName === leftTableName ? columnName : `${tableName}.${columnName}`;
mappings[mappingKey] = valueMap;
// 컬럼명만으로도 접근 가능하도록 추가 저장
mappings[columnName] = { ...(mappings[columnName] || {}), ...valueMap };
}
} catch (error) {
console.error(`좌측 카테고리 값 조회 실패 [${tableName}.${columnName}]:`, error);
}
}
} catch (error) {
console.error(`좌측 카테고리 조회 실패 [${columnName}]:`, error);
console.error(`좌측 카테고리 테이블 컬럼 조회 실패 [${tableName}]:`, error);
}
}
@@ -2073,7 +2165,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
};
loadLeftCategoryMappings();
}, [componentConfig.leftPanel?.tableName, isDesignMode]);
}, [componentConfig.leftPanel?.tableName, componentConfig.leftPanel?.columns, isDesignMode]);
// 우측 테이블 카테고리 매핑 로드 (조인된 테이블 포함)
useEffect(() => {
@@ -3740,9 +3832,22 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
displayFields = configuredColumns.slice(0, 2).map((col: any) => {
const colName = typeof col === "string" ? col : col.name || col.columnName;
const colLabel = typeof col === "object" ? col.label : leftColumnLabels[colName] || colName;
const rawValue = getEntityJoinValue(item, colName);
// 카테고리 매핑이 있으면 라벨로 변환
let displayValue = rawValue;
if (rawValue != null && rawValue !== "") {
const strVal = String(rawValue);
let mapping = leftCategoryMappings[colName];
if (!mapping && colName.includes(".")) {
mapping = leftCategoryMappings[colName.split(".").pop() || colName];
}
if (mapping && mapping[strVal]) {
displayValue = mapping[strVal].label;
}
}
return {
label: colLabel,
value: item[colName],
value: displayValue,
};
});
@@ -3754,10 +3859,21 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
const keys = Object.keys(item).filter(
(k) => k !== "id" && k !== "ID" && k !== "children" && k !== "level" && shouldShowField(k),
);
displayFields = keys.slice(0, 2).map((key) => ({
label: leftColumnLabels[key] || key,
value: item[key],
}));
displayFields = keys.slice(0, 2).map((key) => {
const rawValue = item[key];
let displayValue = rawValue;
if (rawValue != null && rawValue !== "") {
const strVal = String(rawValue);
const mapping = leftCategoryMappings[key];
if (mapping && mapping[strVal]) {
displayValue = mapping[strVal].label;
}
}
return {
label: leftColumnLabels[key] || key,
value: displayValue,
};
});
if (index === 0) {
console.log(" ⚠️ 설정된 컬럼 없음, 자동 선택:", displayFields);
@@ -1955,7 +1955,7 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
{/* ===== 기본 설정 모달 ===== */}
<Dialog open={activeModal === "basic"} onOpenChange={(open) => !open && setActiveModal(null)}>
<DialogContent className="max-h-[85vh] max-w-2xl overflow-y-auto">
<DialogContent container={null} className="max-h-[85vh] max-w-2xl overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-base"> </DialogTitle>
<DialogDescription className="text-xs"> </DialogDescription>
@@ -2033,7 +2033,7 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
{/* ===== 좌측 패널 모달 ===== */}
<Dialog open={activeModal === "left"} onOpenChange={(open) => !open && setActiveModal(null)}>
<DialogContent className="max-h-[85vh] max-w-2xl overflow-y-auto">
<DialogContent container={null} className="max-h-[85vh] max-w-2xl overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-base"> </DialogTitle>
<DialogDescription className="text-xs"> </DialogDescription>
@@ -2715,7 +2715,7 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
{/* ===== 우측 패널 모달 ===== */}
<Dialog open={activeModal === "right"} onOpenChange={(open) => !open && setActiveModal(null)}>
<DialogContent className="max-h-[85vh] max-w-2xl overflow-y-auto">
<DialogContent container={null} className="max-h-[85vh] max-w-2xl overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-base"> </DialogTitle>
<DialogDescription className="text-xs">
@@ -3651,7 +3651,7 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
{/* ===== 추가 탭 모달 ===== */}
<Dialog open={activeModal === "tabs"} onOpenChange={(open) => !open && setActiveModal(null)}>
<DialogContent className="max-h-[85vh] max-w-2xl overflow-y-auto">
<DialogContent container={null} className="max-h-[85vh] max-w-2xl overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-base"> </DialogTitle>
<DialogDescription className="text-xs">
@@ -12,6 +12,7 @@ import { getFilePreviewUrl } from "@/lib/api/file";
import { Button } from "@/components/ui/button";
import { v2EventBus, V2_EVENTS, V2ErrorBoundary } from "@/lib/v2-core";
import { getAdaptiveLabelColor } from "@/lib/utils/darkModeColor";
import { useTabId } from "@/contexts/TabIdContext";
// 🖼️ 테이블 셀 이미지 썸네일 컴포넌트
// objid인 경우 인증된 API로 blob URL 생성, 경로인 경우 직접 URL 사용
@@ -405,6 +406,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 디버그 로그 제거 (성능 최적화)
const currentTabId = useTabId();
const buttonColor = getAdaptiveLabelColor(component.style?.labelColor);
const buttonTextColor = component.config?.buttonTextColor || "#ffffff";
@@ -433,13 +436,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
width: "100%",
height: "100%",
minHeight: isDesignMode ? "300px" : "100%",
...style,
// 런타임에서는 DB의 고정 px 크기를 무시하고 부모에 맞춤
...(!isDesignMode && {
width: "100%",
height: "100%",
minWidth: 0,
}),
...style, // style prop이 위의 기본값들을 덮어씀
};
// ========================================
@@ -695,13 +692,32 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(0);
const [totalItems, setTotalItems] = useState(0);
const [pageInputValue, setPageInputValue] = useState<string>("1");
const [searchTerm, setSearchTerm] = useState("");
const [sortColumn, setSortColumn] = useState<string | null>(null);
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
const hasInitializedSort = useRef(false);
const [columnLabels, setColumnLabels] = useState<Record<string, string>>({});
const [tableLabel, setTableLabel] = useState<string>("");
const [localPageSize, setLocalPageSize] = useState<number>(tableConfig.pagination?.pageSize || 20);
const pageSizeKey = useMemo(() => {
if (!tableConfig.selectedTable) return null;
if (currentTabId) return `pageSize_${currentTabId}_${tableConfig.selectedTable}`;
return `pageSize_${tableConfig.selectedTable}`;
}, [tableConfig.selectedTable, currentTabId]);
const [localPageSize, setLocalPageSize] = useState<number>(() => {
const key =
currentTabId && tableConfig.selectedTable
? `pageSize_${currentTabId}_${tableConfig.selectedTable}`
: tableConfig.selectedTable
? `pageSize_${tableConfig.selectedTable}`
: null;
if (key) {
const val = sessionStorage.getItem(key);
if (val) return Number(val);
}
return tableConfig.pagination?.pageSize || 20;
});
const [displayColumns, setDisplayColumns] = useState<ColumnConfig[]>([]);
const [columnMeta, setColumnMeta] = useState<
Record<string, { webType?: string; codeCategory?: string; inputType?: string; categoryRef?: string }>
@@ -811,9 +827,10 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const [dropTargetColumnIndex, setDropTargetColumnIndex] = useState<number | null>(null);
const [isColumnDragEnabled] = useState<boolean>((tableConfig as any).enableColumnDrag ?? true);
// 🆕 State Persistence: 통합 상태 키
// 🆕 State Persistence: 통합 상태 키 (탭 ID 스코프 + sessionStorage)
const tableStateKey = useMemo(() => {
if (!tableConfig.selectedTable) return null;
if (currentTabId) return `tableState_${currentTabId}_${tableConfig.selectedTable}`;
return `tableState_${tableConfig.selectedTable}`;
}, [tableConfig.selectedTable]);
@@ -1623,7 +1640,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
setError(null);
try {
const page = tableConfig.pagination?.currentPage || currentPage;
const page = currentPage;
const pageSize = localPageSize;
// 🆕 sortColumn이 없으면 defaultSort 설정을 fallback으로 사용
const sortBy = sortColumn || tableConfig.defaultSort?.columnName || undefined;
@@ -1886,7 +1903,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
}
}, [
tableConfig.selectedTable,
tableConfig.pagination?.currentPage,
tableConfig.columns,
currentPage,
localPageSize,
@@ -1929,6 +1945,23 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
}
};
useEffect(() => {
setPageInputValue(String(currentPage));
}, [currentPage]);
const commitPageInput = () => {
const parsed = parseInt(pageInputValue, 10);
if (isNaN(parsed) || pageInputValue.trim() === "") {
setPageInputValue(String(currentPage));
return;
}
const clamped = Math.max(1, Math.min(parsed, totalPages || 1));
if (clamped !== currentPage) {
handlePageChange(clamped);
}
setPageInputValue(String(clamped));
};
const handleSort = (column: string) => {
let newSortColumn = column;
let newSortDirection: "asc" | "desc" = "asc";
@@ -2989,7 +3022,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
};
try {
localStorage.setItem(tableStateKey, JSON.stringify(state));
sessionStorage.setItem(tableStateKey, JSON.stringify(state));
} catch (error) {
console.error("❌ 테이블 상태 저장 실패:", error);
}
@@ -3012,7 +3045,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
if (!tableStateKey) return;
try {
const saved = localStorage.getItem(tableStateKey);
const saved = sessionStorage.getItem(tableStateKey);
if (!saved) return;
const state = JSON.parse(saved);
@@ -3050,7 +3083,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
if (!tableStateKey) return;
try {
localStorage.removeItem(tableStateKey);
sessionStorage.removeItem(tableStateKey);
setColumnWidths({});
setColumnOrder([]);
setSortColumn(null);
@@ -4285,8 +4318,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
return (
<div className="flex max-w-full items-center gap-1.5 text-sm">
<Paperclip className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
<span className="truncate text-primary" title={fileNames}>
<Paperclip className="h-4 w-4 flex-shrink-0 text-gray-500" />
<span className="truncate text-blue-600" title={fileNames}>
{fileNames}
</span>
{files.length > 1 && <span className="text-muted-foreground flex-shrink-0 text-xs">({files.length})</span>}
@@ -4475,28 +4508,32 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// useEffect 훅
// ========================================
// 필터 설정 localStorage 키 생성 (화면별로 독립적)
// 필터 설정 sessionStorage 키 생성 (탭 ID 스코프)
const filterSettingKey = useMemo(() => {
if (!tableConfig.selectedTable) return null;
return screenId
? `tableList_filterSettings_${tableConfig.selectedTable}_screen_${screenId}`
: `tableList_filterSettings_${tableConfig.selectedTable}`;
}, [tableConfig.selectedTable, screenId]);
const base = screenId
? `${tableConfig.selectedTable}_screen_${screenId}`
: tableConfig.selectedTable;
if (currentTabId) return `filterSettings_${currentTabId}_${base}`;
return `filterSettings_${base}`;
}, [tableConfig.selectedTable, screenId, currentTabId]);
// 그룹 설정 localStorage 키 생성 (화면별로 독립적)
// 그룹 설정 sessionStorage 키 생성 (탭 ID 스코프)
const groupSettingKey = useMemo(() => {
if (!tableConfig.selectedTable) return null;
return screenId
? `tableList_groupSettings_${tableConfig.selectedTable}_screen_${screenId}`
: `tableList_groupSettings_${tableConfig.selectedTable}`;
}, [tableConfig.selectedTable, screenId]);
const base = screenId
? `${tableConfig.selectedTable}_screen_${screenId}`
: tableConfig.selectedTable;
if (currentTabId) return `groupSettings_${currentTabId}_${base}`;
return `groupSettings_${base}`;
}, [tableConfig.selectedTable, screenId, currentTabId]);
// 저장된 필터 설정 불러오기
useEffect(() => {
if (!filterSettingKey || visibleColumns.length === 0) return;
try {
const saved = localStorage.getItem(filterSettingKey);
const saved = sessionStorage.getItem(filterSettingKey);
if (saved) {
const savedFilters = JSON.parse(saved);
setVisibleFilterColumns(new Set(savedFilters));
@@ -4515,7 +4552,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
if (!filterSettingKey) return;
try {
localStorage.setItem(filterSettingKey, JSON.stringify(Array.from(visibleFilterColumns)));
sessionStorage.setItem(filterSettingKey, JSON.stringify(Array.from(visibleFilterColumns)));
setIsFilterSettingOpen(false);
toast.success("검색 필터 설정이 저장되었습니다");
@@ -4570,7 +4607,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
if (!groupSettingKey) return;
try {
localStorage.setItem(groupSettingKey, JSON.stringify(groupByColumns));
sessionStorage.setItem(groupSettingKey, JSON.stringify(groupByColumns));
} catch (error) {
console.error("그룹 설정 저장 실패:", error);
}
@@ -4650,7 +4687,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
setGroupByColumns([]);
setCollapsedGroups(new Set());
if (groupSettingKey) {
localStorage.removeItem(groupSettingKey);
sessionStorage.removeItem(groupSettingKey);
}
toast.success("그룹이 해제되었습니다");
}, [groupSettingKey]);
@@ -4834,7 +4871,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
if (!groupSettingKey || visibleColumns.length === 0) return;
try {
const saved = localStorage.getItem(groupSettingKey);
const saved = sessionStorage.getItem(groupSettingKey);
if (saved) {
const savedGroups = JSON.parse(saved);
setGroupByColumns(savedGroups);
@@ -5134,7 +5171,10 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 페이지 크기 변경 핸들러
const handlePageSizeChange = (newSize: number) => {
setLocalPageSize(newSize);
setCurrentPage(1); // 페이지 크기 변경 시 첫 페이지로 이동
setCurrentPage(1);
if (pageSizeKey) {
sessionStorage.setItem(pageSizeKey, String(newSize));
}
if (onConfigChange) {
onConfigChange({
...tableConfig,
@@ -5143,8 +5183,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
}
};
const pageSizeOptions = tableConfig.pagination?.pageSizeOptions || [5, 10, 20, 50, 100];
return (
<div className="border-border bg-background relative flex h-14 w-full flex-shrink-0 items-center justify-center border-t-2 px-4 sm:h-[60px] sm:px-6">
{/* 좌측: 페이지 크기 입력 */}
@@ -5190,9 +5228,28 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
<ChevronLeft className="h-3 w-3 sm:h-4 sm:w-4" />
</Button>
<span className="text-foreground min-w-[60px] text-center text-xs font-medium sm:min-w-[80px] sm:text-sm">
{currentPage} / {totalPages || 1}
</span>
<div className="flex items-center gap-1 sm:gap-2">
<input
type="text"
inputMode="numeric"
value={pageInputValue}
onChange={(e) => setPageInputValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
commitPageInput();
(e.target as HTMLInputElement).blur();
}
}}
onBlur={commitPageInput}
onFocus={(e) => e.target.select()}
disabled={loading}
className="border-input bg-background focus:ring-ring h-7 w-10 rounded-md border px-1 text-center text-xs font-medium focus:ring-1 focus:outline-none sm:h-8 sm:w-12 sm:text-sm"
/>
<span className="text-muted-foreground text-xs sm:text-sm">/</span>
<span className="text-foreground text-xs font-medium sm:text-sm">
{totalPages || 1}
</span>
</div>
<Button
variant="outline"
@@ -5227,7 +5284,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
<div className="flex flex-col gap-1">
<div className="text-muted-foreground px-2 py-1 text-xs font-medium">Excel</div>
<Button variant="ghost" size="sm" className="justify-start text-xs" onClick={() => exportToExcel(true)}>
<FileSpreadsheet className="mr-2 h-3 w-3 text-emerald-600" />
<FileSpreadsheet className="mr-2 h-3 w-3 text-green-600" />
Excel
</Button>
<Button
@@ -5237,13 +5294,13 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
onClick={() => exportToExcel(false)}
disabled={selectedRows.size === 0}
>
<FileSpreadsheet className="mr-2 h-3 w-3 text-emerald-600" />
<FileSpreadsheet className="mr-2 h-3 w-3 text-green-600" />
({selectedRows.size})
</Button>
<div className="border-border my-1 border-t" />
<div className="text-muted-foreground px-2 py-1 text-xs font-medium">PDF/</div>
<Button variant="ghost" size="sm" className="justify-start text-xs" onClick={() => exportToPdf(true)}>
<FileText className="mr-2 h-3 w-3 text-destructive" />
<FileText className="mr-2 h-3 w-3 text-red-600" />
PDF
</Button>
<Button
@@ -5253,7 +5310,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
onClick={() => exportToPdf(false)}
disabled={selectedRows.size === 0}
>
<FileText className="mr-2 h-3 w-3 text-destructive" />
<FileText className="mr-2 h-3 w-3 text-red-600" />
({selectedRows.size})
</Button>
</div>
@@ -5289,6 +5346,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
localPageSize,
onConfigChange,
tableConfig,
pageInputValue,
]);
// ========================================
@@ -5300,7 +5358,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
onDragStart: isDesignMode ? onDragStart : undefined,
onDragEnd: isDesignMode ? onDragEnd : undefined,
draggable: isDesignMode,
className: cn("w-full h-full overflow-hidden", className, isDesignMode && "cursor-move"),
className: cn("w-full h-full", className, isDesignMode && "cursor-move"), // customer-item-mapping과 동일
style: componentStyle,
};
@@ -5370,7 +5428,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
</div>
)}
<div style={{ flex: 1, overflow: "auto", WebkitOverflowScrolling: "touch" }}>
<div style={{ flex: 1, overflow: "hidden" }}>
<SingleTableWithSticky
data={data}
columns={visibleColumns}
@@ -5436,7 +5494,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
className="h-7 text-xs"
title="Excel 내보내기"
>
<FileSpreadsheet className="mr-1 h-3 w-3 text-emerald-600" />
<FileSpreadsheet className="mr-1 h-3 w-3 text-green-600" />
Excel
</Button>
)}
@@ -5448,7 +5506,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
className="h-7 text-xs"
title="PDF 내보내기"
>
<FileText className="mr-1 h-3 w-3 text-destructive" />
<FileText className="mr-1 h-3 w-3 text-red-600" />
PDF
</Button>
)}
@@ -5680,7 +5738,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
width: "100%",
height: "100%",
overflow: "auto",
WebkitOverflowScrolling: "touch",
}}
onScroll={handleVirtualScroll}
>
@@ -5691,7 +5748,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
borderCollapse: "collapse",
width: "100%",
tableLayout: "fixed",
minWidth: "400px",
}}
>
{/* 헤더 (sticky) */}
@@ -5909,7 +5965,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
{/* 리사이즈 핸들 (체크박스 제외) */}
{columnIndex < visibleColumns.length - 1 && column.columnName !== "__checkbox__" && (
<div
className="absolute top-0 right-0 z-20 h-full w-2 cursor-col-resize hover:bg-primary"
className="absolute top-0 right-0 z-20 h-full w-2 cursor-col-resize hover:bg-blue-500"
style={{ marginRight: "-4px", paddingLeft: "4px", paddingRight: "4px" }}
onClick={(e) => e.stopPropagation()} // 정렬 클릭 방지
onMouseDown={(e) => {
@@ -6262,11 +6318,11 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 🆕 배치 편집: 수정된 셀 스타일 (노란 배경)
isModified && !cellValidationError && "bg-amber-100 dark:bg-amber-900/40",
// 🆕 유효성 에러: 빨간 테두리 및 배경
cellValidationError && "bg-destructive/10 ring-2 ring-destructive ring-inset dark:bg-destructive/15",
cellValidationError && "bg-red-50 ring-2 ring-red-500 ring-inset dark:bg-red-950/40",
// 🆕 검색 하이라이트 스타일 (노란 배경)
isSearchHighlighted && !isCellFocused && "bg-yellow-200 dark:bg-yellow-700/50",
// 🆕 편집 불가 컬럼 스타일 (연한 회색 배경)
column.editable === false && "bg-muted dark:bg-foreground/30",
column.editable === false && "bg-gray-50 dark:bg-gray-900/30",
)}
// 🆕 유효성 에러 툴팁
title={cellValidationError || undefined}
@@ -6659,7 +6715,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
{/* 행 삭제 */}
<button
className="hover:bg-destructive hover:text-destructive-foreground flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm text-destructive"
className="hover:bg-destructive hover:text-destructive-foreground flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm text-red-600"
onClick={async () => {
if (confirm("이 행을 삭제하시겠습니까?")) {
try {
@@ -6726,7 +6782,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
variant="ghost"
size="sm"
onClick={() => removeFilterGroup(group.id)}
className="h-6 w-6 p-0 text-destructive hover:text-destructive"
className="h-6 w-6 p-0 text-red-500 hover:text-red-700"
>
<X className="h-4 w-4" />
</Button>
@@ -6786,7 +6842,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
variant="ghost"
size="sm"
onClick={() => removeFilterCondition(group.id, condition.id)}
className="h-6 w-6 p-0 text-destructive hover:text-destructive"
className="h-6 w-6 p-0 text-red-500 hover:text-red-700"
disabled={group.conditions.length === 1}
>
<X className="h-3 w-3" />
@@ -16,12 +16,13 @@ import "./pop-text";
import "./pop-icon";
import "./pop-dashboard";
import "./pop-card-list";
import "./pop-card-list-v2";
import "./pop-button";
import "./pop-string-list";
import "./pop-search";
import "./pop-status-bar";
import "./pop-field";
// 향후 추가될 컴포넌트들:
// import "./pop-list";
import "./pop-scanner";
import "./pop-profile";
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,104 @@
"use client";
/**
* pop-card-list-v2
*
* .
* CSS Grid .
*/
import React from "react";
import { LayoutGrid, Package } from "lucide-react";
import type { PopCardListV2Config } from "../types";
import { CARD_SCROLL_DIRECTION_LABELS, CARD_SIZE_LABELS } from "../types";
interface PopCardListV2PreviewProps {
config?: PopCardListV2Config;
}
export function PopCardListV2PreviewComponent({ config }: PopCardListV2PreviewProps) {
const scrollDirection = config?.scrollDirection || "vertical";
const cardSize = config?.cardSize || "medium";
const dataSource = config?.dataSource;
const cardGrid = config?.cardGrid;
const hasTable = !!dataSource?.tableName;
const cellCount = cardGrid?.cells?.length || 0;
return (
<div className="flex h-full w-full flex-col bg-muted/30 p-3">
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2 text-muted-foreground">
<LayoutGrid className="h-4 w-4" />
<span className="text-xs font-medium"> V2</span>
</div>
<div className="flex gap-1">
<span className="rounded bg-primary/10 px-1.5 py-0.5 text-[9px] text-primary">
{CARD_SCROLL_DIRECTION_LABELS[scrollDirection]}
</span>
<span className="rounded bg-secondary px-1.5 py-0.5 text-[9px] text-secondary-foreground">
{CARD_SIZE_LABELS[cardSize]}
</span>
</div>
</div>
{!hasTable ? (
<div className="flex flex-1 items-center justify-center">
<div className="text-center">
<Package className="mx-auto mb-2 h-8 w-8 text-muted-foreground/50" />
<p className="text-xs text-muted-foreground"> </p>
</div>
</div>
) : (
<>
<div className="mb-2 text-center">
<span className="rounded bg-muted px-2 py-0.5 text-[10px] text-muted-foreground">
{dataSource!.tableName}
</span>
<span className="ml-1 text-[10px] text-muted-foreground/60">
({cellCount})
</span>
</div>
<div className="flex flex-1 flex-col gap-2">
{[0, 1].map((cardIdx) => (
<div key={cardIdx} className="rounded-md border bg-card p-2">
{cellCount === 0 ? (
<div className="flex h-12 items-center justify-center">
<span className="text-[10px] text-muted-foreground"> </span>
</div>
) : (
<div
style={{
display: "grid",
gridTemplateColumns: cardGrid!.colWidths?.length
? cardGrid!.colWidths.map((w) => w || "1fr").join(" ")
: `repeat(${cardGrid!.cols || 1}, 1fr)`,
gridTemplateRows: `repeat(${cardGrid!.rows || 1}, minmax(16px, auto))`,
gap: "2px",
}}
>
{cardGrid!.cells.map((cell) => (
<div
key={cell.id}
className="rounded border border-dashed border-border/50 bg-muted/20 px-1 py-0.5"
style={{
gridColumn: `${cell.col} / span ${cell.colSpan || 1}`,
gridRow: `${cell.row} / span ${cell.rowSpan || 1}`,
}}
>
<span className="text-[8px] text-muted-foreground">
{cell.type}
{cell.columnName ? `: ${cell.columnName}` : ""}
</span>
</div>
))}
</div>
)}
</div>
))}
</div>
</>
)}
</div>
);
}
@@ -0,0 +1,733 @@
"use client";
/**
* pop-card-list-v2
*
* CardV2Grid에서 type별 dispatch로 .
* pop-card-list의 pop-string-list의 CardModeView .
*/
import React, { useMemo, useState } from "react";
import {
ShoppingCart, X, Package, Truck, Box, Archive, Heart, Star,
Loader2, CheckCircle2, CircleDot, Clock,
type LucideIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import type { CardCellDefinitionV2, PackageEntry, TimelineProcessStep, ActionButtonDef } from "../types";
import { DEFAULT_CARD_IMAGE, VIRTUAL_SUB_STATUS, VIRTUAL_SUB_SEMANTIC } from "../types";
import type { ButtonVariant } from "../pop-button";
type RowData = Record<string, unknown>;
// ===== 공통 유틸 =====
const LUCIDE_ICON_MAP: Record<string, LucideIcon> = {
ShoppingCart, Package, Truck, Box, Archive, Heart, Star,
};
function DynamicLucideIcon({ name, size = 20 }: { name?: string; size?: number }) {
if (!name) return <ShoppingCart size={size} />;
const IconComp = LUCIDE_ICON_MAP[name];
if (!IconComp) return <ShoppingCart size={size} />;
return <IconComp size={size} />;
}
function formatValue(value: unknown): string {
if (value === null || value === undefined) return "-";
if (typeof value === "number") return value.toLocaleString();
if (typeof value === "boolean") return value ? "예" : "아니오";
if (value instanceof Date) return value.toLocaleDateString();
if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}/.test(value)) {
const date = new Date(value);
if (!isNaN(date.getTime())) {
return `${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
}
}
return String(value);
}
const FONT_SIZE_MAP = { xs: "10px", sm: "11px", md: "12px", lg: "14px" } as const;
const FONT_WEIGHT_MAP = { normal: 400, medium: 500, bold: 700 } as const;
// ===== 셀 렌더러 Props =====
export interface CellRendererProps {
cell: CardCellDefinitionV2;
row: RowData;
inputValue?: number;
isCarted?: boolean;
isButtonLoading?: boolean;
onInputClick?: (e: React.MouseEvent) => void;
onCartAdd?: () => void;
onCartCancel?: () => void;
onButtonClick?: (cell: CardCellDefinitionV2, row: RowData) => void;
onActionButtonClick?: (taskPreset: string, row: RowData, buttonConfig?: Record<string, unknown>) => void;
onEnterSelectMode?: (whenStatus: string, buttonConfig: Record<string, unknown>) => void;
packageEntries?: PackageEntry[];
inputUnit?: string;
}
// ===== 메인 디스패치 =====
export function renderCellV2(props: CellRendererProps): React.ReactNode {
switch (props.cell.type) {
case "text":
return <TextCell {...props} />;
case "field":
return <FieldCell {...props} />;
case "image":
return <ImageCell {...props} />;
case "badge":
return <BadgeCell {...props} />;
case "button":
return <ButtonCell {...props} />;
case "number-input":
return <NumberInputCell {...props} />;
case "cart-button":
return <CartButtonCell {...props} />;
case "package-summary":
return <PackageSummaryCell {...props} />;
case "status-badge":
return <StatusBadgeCell {...props} />;
case "timeline":
return <TimelineCell {...props} />;
case "action-buttons":
return <ActionButtonsCell {...props} />;
case "footer-status":
return <FooterStatusCell {...props} />;
default:
return <span className="text-[10px] text-muted-foreground"> </span>;
}
}
// ===== 1. text =====
function TextCell({ cell, row }: CellRendererProps) {
const value = cell.columnName ? row[cell.columnName] : "";
const fs = FONT_SIZE_MAP[cell.fontSize || "md"];
const fw = FONT_WEIGHT_MAP[cell.fontWeight || "normal"];
return (
<span
className="truncate"
style={{ fontSize: fs, fontWeight: fw, color: cell.textColor || undefined }}
>
{formatValue(value)}
</span>
);
}
// ===== 2. field (라벨+값) =====
function FieldCell({ cell, row, inputValue }: CellRendererProps) {
const valueType = cell.valueType || "column";
const fs = FONT_SIZE_MAP[cell.fontSize || "md"];
const displayValue = useMemo(() => {
if (valueType !== "formula") {
const raw = cell.columnName ? row[cell.columnName] : undefined;
const formatted = formatValue(raw);
return cell.unit ? `${formatted} ${cell.unit}` : formatted;
}
if (cell.formulaLeft && cell.formulaOperator) {
const rightVal =
(cell.formulaRightType || "input") === "input"
? (inputValue ?? 0)
: Number(row[cell.formulaRight || ""] ?? 0);
const leftVal = Number(row[cell.formulaLeft] ?? 0);
let result: number | null = null;
switch (cell.formulaOperator) {
case "+": result = leftVal + rightVal; break;
case "-": result = leftVal - rightVal; break;
case "*": result = leftVal * rightVal; break;
case "/": result = rightVal !== 0 ? leftVal / rightVal : null; break;
}
if (result !== null && isFinite(result)) {
const formatted = (Math.round(result * 100) / 100).toLocaleString();
return cell.unit ? `${formatted} ${cell.unit}` : formatted;
}
return "-";
}
return "-";
}, [valueType, cell, row, inputValue]);
const isFormula = valueType === "formula";
const isLabelLeft = cell.labelPosition === "left";
return (
<div
className={isLabelLeft ? "flex items-baseline gap-1" : "flex flex-col"}
style={{ fontSize: fs }}
>
{cell.label && (
<span className="shrink-0 text-[10px] text-muted-foreground">
{cell.label}{isLabelLeft ? ":" : ""}
</span>
)}
<span
className="truncate font-medium"
style={{ color: cell.textColor || (isFormula ? "#ea580c" : undefined) }}
>
{displayValue}
</span>
</div>
);
}
// ===== 3. image =====
function ImageCell({ cell, row }: CellRendererProps) {
const value = cell.columnName ? row[cell.columnName] : "";
const imageUrl = value ? String(value) : (cell.defaultImage || DEFAULT_CARD_IMAGE);
return (
<div className="flex h-full w-full items-center justify-center overflow-hidden rounded-md border bg-muted/30">
<img
src={imageUrl}
alt={cell.label || ""}
className="h-full w-full object-contain p-1"
onError={(e) => {
const target = e.target as HTMLImageElement;
if (target.src !== DEFAULT_CARD_IMAGE) target.src = DEFAULT_CARD_IMAGE;
}}
/>
</div>
);
}
// ===== 4. badge =====
function BadgeCell({ cell, row }: CellRendererProps) {
const value = cell.columnName ? row[cell.columnName] : "";
return (
<span className="inline-flex items-center rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-medium text-primary">
{formatValue(value)}
</span>
);
}
// ===== 5. button =====
function ButtonCell({ cell, row, isButtonLoading, onButtonClick }: CellRendererProps) {
return (
<Button
variant={cell.buttonVariant || "outline"}
size="sm"
className="h-7 text-[10px]"
disabled={isButtonLoading}
onClick={(e) => {
e.stopPropagation();
onButtonClick?.(cell, row);
}}
>
{isButtonLoading ? (
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
) : null}
{cell.label || formatValue(cell.columnName ? row[cell.columnName] : "")}
</Button>
);
}
// ===== 6. number-input =====
function NumberInputCell({ cell, row, inputValue, onInputClick }: CellRendererProps) {
const unit = cell.inputUnit || "EA";
return (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onInputClick?.(e);
}}
className="w-full rounded-lg border-2 border-input bg-background px-2 py-1.5 text-center hover:border-primary active:bg-muted"
>
<span className="block text-lg font-bold leading-tight">
{(inputValue ?? 0).toLocaleString()}
</span>
<span className="block text-[12px] text-muted-foreground">{unit}</span>
</button>
);
}
// ===== 7. cart-button =====
function CartButtonCell({ cell, row, isCarted, onCartAdd, onCartCancel }: CellRendererProps) {
const iconSize = 18;
const label = cell.cartLabel || "담기";
const cancelLabel = cell.cartCancelLabel || "취소";
if (isCarted) {
return (
<button
type="button"
onClick={(e) => { e.stopPropagation(); onCartCancel?.(); }}
className="flex w-full flex-col items-center justify-center gap-0.5 rounded-xl bg-destructive px-2 py-1.5 text-destructive-foreground transition-colors duration-150 hover:bg-destructive/90 active:bg-destructive/80"
>
<X size={iconSize} />
<span className="text-[10px] font-semibold leading-tight">{cancelLabel}</span>
</button>
);
}
return (
<button
type="button"
onClick={(e) => { e.stopPropagation(); onCartAdd?.(); }}
className="flex w-full flex-col items-center justify-center gap-0.5 rounded-xl bg-linear-to-br from-amber-400 to-orange-500 px-2 py-1.5 text-white transition-colors duration-150 hover:from-amber-500 hover:to-orange-600 active:from-amber-600 active:to-orange-700"
>
{cell.cartIconType === "emoji" && cell.cartIconValue ? (
<span style={{ fontSize: `${iconSize}px` }}>{cell.cartIconValue}</span>
) : (
<DynamicLucideIcon name={cell.cartIconValue} size={iconSize} />
)}
<span className="text-[10px] font-semibold leading-tight">{label}</span>
</button>
);
}
// ===== 8. package-summary =====
function PackageSummaryCell({ cell, packageEntries, inputUnit }: CellRendererProps) {
if (!packageEntries || packageEntries.length === 0) return null;
return (
<div className="w-full border-t bg-emerald-50">
{packageEntries.map((entry, idx) => (
<div key={idx} className="flex items-center justify-between px-3 py-1.5">
<div className="flex items-center gap-2">
<span className="rounded-full bg-emerald-500 px-2 py-0.5 text-[10px] font-bold text-white">
</span>
<Package className="h-4 w-4 text-emerald-600" />
<span className="text-xs font-medium text-emerald-700">
{entry.packageCount}{entry.unitLabel} x {entry.quantityPerUnit}
</span>
</div>
<span className="text-xs font-bold text-emerald-700">
= {entry.totalQuantity.toLocaleString()}{inputUnit || "EA"}
</span>
</div>
))}
</div>
);
}
// ===== 9. status-badge =====
const STATUS_COLORS: Record<string, { bg: string; text: string }> = {
waiting: { bg: "#94a3b820", text: "#64748b" },
accepted: { bg: "#3b82f620", text: "#2563eb" },
in_progress: { bg: "#f59e0b20", text: "#d97706" },
completed: { bg: "#10b98120", text: "#059669" },
};
function StatusBadgeCell({ cell, row }: CellRendererProps) {
const hasSubStatus = row[VIRTUAL_SUB_STATUS] !== undefined;
const effectiveValue = hasSubStatus
? row[VIRTUAL_SUB_STATUS]
: (cell.statusColumn ? row[cell.statusColumn] : (cell.columnName ? row[cell.columnName] : ""));
const strValue = String(effectiveValue || "");
const mapped = cell.statusMap?.find((m) => m.value === strValue);
if (mapped) {
return (
<span
className="inline-flex items-center rounded-full px-2.5 py-0.5 text-[10px] font-semibold"
style={{ backgroundColor: `${mapped.color}20`, color: mapped.color }}
>
{mapped.label}
</span>
);
}
const defaultColors = STATUS_COLORS[strValue];
if (defaultColors) {
const labelMap: Record<string, string> = {
waiting: "대기", accepted: "접수", in_progress: "진행", completed: "완료",
};
return (
<span
className="inline-flex items-center rounded-full px-2.5 py-0.5 text-[10px] font-semibold"
style={{ backgroundColor: defaultColors.bg, color: defaultColors.text }}
>
{labelMap[strValue] || strValue}
</span>
);
}
return (
<span className="inline-flex items-center rounded-full bg-muted px-2.5 py-0.5 text-[10px] font-medium text-muted-foreground">
{formatValue(effectiveValue)}
</span>
);
}
// ===== 10. timeline =====
type TimelineStyle = { chipBg: string; chipText: string; icon: React.ReactNode };
const TIMELINE_SEMANTIC_STYLES: Record<string, TimelineStyle> = {
done: { chipBg: "#10b981", chipText: "#ffffff", icon: <CheckCircle2 className="h-2.5 w-2.5" /> },
active: { chipBg: "#3b82f6", chipText: "#ffffff", icon: <CircleDot className="h-2.5 w-2.5 animate-pulse" /> },
pending: { chipBg: "#e2e8f0", chipText: "#64748b", icon: <Clock className="h-2.5 w-2.5" /> },
};
// 레거시 status 값 → semantic 매핑 (기존 데이터 호환)
const LEGACY_STATUS_TO_SEMANTIC: Record<string, string> = {
completed: "done", in_progress: "active", accepted: "active", waiting: "pending",
};
function getTimelineStyle(step: TimelineProcessStep): TimelineStyle {
if (step.semantic) return TIMELINE_SEMANTIC_STYLES[step.semantic] || TIMELINE_SEMANTIC_STYLES.pending;
const fallback = LEGACY_STATUS_TO_SEMANTIC[step.status];
return TIMELINE_SEMANTIC_STYLES[fallback || "pending"];
}
function TimelineCell({ cell, row }: CellRendererProps) {
const processFlow = row.__processFlow__ as TimelineProcessStep[] | undefined;
if (!processFlow || processFlow.length === 0) {
const fallback = cell.processColumn ? row[cell.processColumn] : "";
return (
<span className="text-[10px] text-muted-foreground">
{formatValue(fallback)}
</span>
);
}
const maxVisible = cell.visibleCount || 5;
const currentIdx = processFlow.findIndex((s) => s.isCurrent);
type DisplayItem =
| { kind: "step"; step: TimelineProcessStep }
| { kind: "count"; count: number; side: "before" | "after" };
// 현재 항목 기준으로 앞뒤 배분하여 축약
const displayItems = useMemo((): DisplayItem[] => {
if (processFlow.length <= maxVisible) {
return processFlow.map((s) => ({ kind: "step" as const, step: s }));
}
const effectiveIdx = Math.max(0, currentIdx);
const priority = cell.timelinePriority || "before";
// 숫자칩 2개를 제외한 나머지를 앞뒤로 배분 (priority에 따라 여분 슬롯 방향 결정)
const slotForSteps = maxVisible - 2;
const half = Math.floor(slotForSteps / 2);
const extra = slotForSteps - half - 1;
const beforeSlots = priority === "before" ? Math.max(half, extra) : Math.min(half, extra);
const afterSlots = slotForSteps - beforeSlots - 1;
let startIdx = effectiveIdx - beforeSlots;
let endIdx = effectiveIdx + afterSlots;
// 경계 보정
if (startIdx < 0) {
endIdx = Math.min(processFlow.length - 1, endIdx + Math.abs(startIdx));
startIdx = 0;
}
if (endIdx >= processFlow.length) {
startIdx = Math.max(0, startIdx - (endIdx - processFlow.length + 1));
endIdx = processFlow.length - 1;
}
const items: DisplayItem[] = [];
const beforeCount = startIdx;
const afterCount = processFlow.length - 1 - endIdx;
if (beforeCount > 0) {
items.push({ kind: "count", count: beforeCount, side: "before" });
}
for (let i = startIdx; i <= endIdx; i++) {
items.push({ kind: "step", step: processFlow[i] });
}
if (afterCount > 0) {
items.push({ kind: "count", count: afterCount, side: "after" });
}
return items;
}, [processFlow, maxVisible, currentIdx]);
const [modalOpen, setModalOpen] = useState(false);
const completedCount = processFlow.filter((s) => (s.semantic || LEGACY_STATUS_TO_SEMANTIC[s.status]) === "done").length;
const totalCount = processFlow.length;
return (
<>
<div
className={cn(
"flex w-full items-center gap-0.5 overflow-hidden px-0.5",
cell.showDetailModal !== false && "cursor-pointer",
cell.align === "center" ? "justify-center" : cell.align === "right" ? "justify-end" : "justify-start",
)}
onClick={cell.showDetailModal !== false ? (e) => { e.stopPropagation(); setModalOpen(true); } : undefined}
title={cell.showDetailModal !== false ? "클릭하여 전체 현황 보기" : undefined}
>
{displayItems.map((item, idx) => {
const isLast = idx === displayItems.length - 1;
if (item.kind === "count") {
return (
<React.Fragment key={`cnt-${item.side}`}>
<div
className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-muted text-[8px] font-bold text-muted-foreground"
title={item.side === "before" ? `이전 ${item.count}` : `이후 ${item.count}`}
>
{item.count}
</div>
{!isLast && <div className="h-px w-1.5 shrink-0 border-t border-dashed border-muted-foreground/40" />}
</React.Fragment>
);
}
const styles = getTimelineStyle(item.step);
return (
<React.Fragment key={item.step.seqNo}>
<div
className="flex shrink-0 items-center gap-0.5 rounded-full px-1.5 py-0.5"
style={{
backgroundColor: styles.chipBg,
color: styles.chipText,
outline: item.step.isCurrent ? "2px solid #2563eb" : "none",
outlineOffset: "1px",
}}
title={`${item.step.seqNo}. ${item.step.processName} (${item.step.status})`}
>
{styles.icon}
<span className="max-w-[48px] truncate text-[9px] font-medium leading-tight">
{item.step.processName}
</span>
</div>
{!isLast && <div className="h-px w-1.5 shrink-0 border-t border-dashed border-muted-foreground/40" />}
</React.Fragment>
);
})}
</div>
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
{totalCount} {completedCount}
</DialogDescription>
</DialogHeader>
<div className="space-y-0">
{processFlow.map((step, idx) => {
const styles = getTimelineStyle(step);
const sem = step.semantic || LEGACY_STATUS_TO_SEMANTIC[step.status] || "pending";
const statusLabel = sem === "done" ? "완료" : sem === "active" ? "진행" : "대기";
return (
<div key={step.seqNo} className="flex items-center">
{/* 세로 연결선 + 아이콘 */}
<div className="flex w-8 shrink-0 flex-col items-center">
{idx > 0 && <div className="h-3 w-px bg-border" />}
<div
className="flex h-6 w-6 items-center justify-center rounded-full"
style={{ backgroundColor: styles.chipBg, color: styles.chipText }}
>
{styles.icon}
</div>
{idx < processFlow.length - 1 && <div className="h-3 w-px bg-border" />}
</div>
{/* 항목 정보 */}
<div className={cn(
"ml-2 flex flex-1 items-center justify-between rounded-md px-3 py-2",
step.isCurrent && "bg-primary/5 ring-1 ring-primary/30",
)}>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">{step.seqNo}</span>
<span className={cn(
"text-sm",
step.isCurrent ? "font-semibold" : "font-medium",
)}>
{step.processName}
</span>
{step.isCurrent && (
<Star className="h-3 w-3 fill-primary text-primary" />
)}
</div>
<span
className="rounded-full px-2 py-0.5 text-[10px] font-medium"
style={{ backgroundColor: styles.chipBg, color: styles.chipText }}
>
{statusLabel}
</span>
</div>
</div>
);
})}
</div>
{/* 하단 진행률 바 */}
<div className="space-y-1 pt-2">
<div className="flex justify-between text-xs text-muted-foreground">
<span></span>
<span>{totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0}%</span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-secondary">
<div
className="h-full rounded-full bg-primary transition-all duration-300"
style={{ width: `${totalCount > 0 ? (completedCount / totalCount) * 100 : 0}%` }}
/>
</div>
</div>
</DialogContent>
</Dialog>
</>
);
}
// ===== 11. action-buttons =====
function evaluateShowCondition(btn: ActionButtonDef, row: RowData): "visible" | "disabled" | "hidden" {
const cond = btn.showCondition;
if (!cond || cond.type === "always") return "visible";
let matched = false;
if (cond.type === "timeline-status") {
const subStatus = row[VIRTUAL_SUB_STATUS];
matched = subStatus !== undefined && String(subStatus) === cond.value;
} else if (cond.type === "column-value" && cond.column) {
matched = String(row[cond.column] ?? "") === (cond.value ?? "");
} else {
return "visible";
}
if (matched) return "visible";
return cond.unmatchBehavior === "disabled" ? "disabled" : "hidden";
}
function ActionButtonsCell({ cell, row, onActionButtonClick, onEnterSelectMode }: CellRendererProps) {
const processFlow = row.__processFlow__ as { isCurrent: boolean; processId?: string | number }[] | undefined;
const currentProcess = processFlow?.find((s) => s.isCurrent);
const currentProcessId = currentProcess?.processId;
if (cell.actionButtons && cell.actionButtons.length > 0) {
const evaluated = cell.actionButtons.map((btn) => ({
btn,
state: evaluateShowCondition(btn, row),
}));
const activeBtn = evaluated.find((e) => e.state === "visible");
const disabledBtn = activeBtn ? null : evaluated.find((e) => e.state === "disabled");
const pick = activeBtn || disabledBtn;
if (!pick) return null;
const { btn, state } = pick;
return (
<div className="flex items-center gap-1">
<Button
variant={btn.variant || "outline"}
size="sm"
disabled={state === "disabled"}
className="h-7 text-[10px]"
onClick={(e) => {
e.stopPropagation();
const actions = (btn.clickActions && btn.clickActions.length > 0) ? btn.clickActions : [btn.clickAction];
const firstAction = actions[0];
const config: Record<string, unknown> = {
...firstAction,
__allActions: actions,
selectModeConfig: firstAction.selectModeButtons
? { filterStatus: btn.showCondition?.value || "", buttons: firstAction.selectModeButtons }
: undefined,
};
if (currentProcessId !== undefined) config.__processId = currentProcessId;
if (firstAction.type === "select-mode" && onEnterSelectMode) {
onEnterSelectMode(btn.showCondition?.value || "", config);
return;
}
onActionButtonClick?.(btn.label, row, config);
}}
>
{btn.label}
</Button>
</div>
);
}
// 기존 구조 (actionRules) 폴백
const hasSubStatus = row[VIRTUAL_SUB_STATUS] !== undefined;
const statusValue = hasSubStatus
? String(row[VIRTUAL_SUB_STATUS] || "")
: (cell.statusColumn ? String(row[cell.statusColumn] || "") : (cell.columnName ? String(row[cell.columnName] || "") : ""));
const rules = cell.actionRules || [];
const matchedRule = rules.find((r) => r.whenStatus === statusValue);
if (!matchedRule) return null;
return (
<div className="flex items-center gap-1">
{matchedRule.buttons.map((btn, idx) => (
<Button
key={idx}
variant={btn.variant || "outline"}
size="sm"
className="h-7 text-[10px]"
onClick={(e) => {
e.stopPropagation();
const config = { ...(btn as Record<string, unknown>) };
if (currentProcessId !== undefined) config.__processId = currentProcessId;
if (btn.clickMode === "select-mode" && onEnterSelectMode) {
onEnterSelectMode(matchedRule.whenStatus, config);
return;
}
onActionButtonClick?.(btn.taskPreset, row, config);
}}
>
{btn.label}
</Button>
))}
</div>
);
}
// ===== 12. footer-status =====
function FooterStatusCell({ cell, row }: CellRendererProps) {
const value = cell.footerStatusColumn ? row[cell.footerStatusColumn] : "";
const strValue = String(value || "");
const mapped = cell.footerStatusMap?.find((m) => m.value === strValue);
if (!strValue && !cell.footerLabel) return null;
return (
<div
className="flex w-full items-center justify-between px-2 py-1"
style={{ borderTop: cell.showTopBorder !== false ? "1px solid hsl(var(--border))" : "none" }}
>
{cell.footerLabel && (
<span className="text-[10px] text-muted-foreground">{cell.footerLabel}</span>
)}
{mapped ? (
<span
className="inline-flex items-center rounded-full px-2 py-0.5 text-[9px] font-semibold"
style={{ backgroundColor: `${mapped.color}20`, color: mapped.color }}
>
{mapped.label}
</span>
) : strValue ? (
<span className="text-[10px] font-medium text-muted-foreground">
{strValue}
</span>
) : null}
</div>
);
}
@@ -0,0 +1,61 @@
"use client";
/**
* pop-card-list-v2
*
* import side-effect로 PopComponentRegistry에
*/
import { PopComponentRegistry } from "../../PopComponentRegistry";
import { PopCardListV2Component } from "./PopCardListV2Component";
import { PopCardListV2ConfigPanel } from "./PopCardListV2Config";
import { PopCardListV2PreviewComponent } from "./PopCardListV2Preview";
import type { PopCardListV2Config } from "../types";
const defaultConfig: PopCardListV2Config = {
dataSource: { tableName: "" },
cardGrid: {
rows: 1,
cols: 1,
colWidths: ["1fr"],
rowHeights: ["32px"],
gap: 4,
showCellBorder: true,
cells: [],
},
gridColumns: 3,
cardGap: 8,
scrollDirection: "vertical",
overflow: { mode: "loadMore", visibleCount: 6, loadMoreCount: 6 },
cardClickAction: "none",
};
PopComponentRegistry.registerComponent({
id: "pop-card-list-v2",
name: "카드 목록 V2",
description: "슬롯 기반 카드 레이아웃 (CSS Grid + 셀 타입별 렌더링)",
category: "display",
icon: "LayoutGrid",
component: PopCardListV2Component,
configPanel: PopCardListV2ConfigPanel,
preview: PopCardListV2PreviewComponent,
defaultProps: defaultConfig,
connectionMeta: {
sendable: [
{ key: "selected_row", label: "선택된 행", type: "selected_row", category: "data", description: "사용자가 선택한 카드의 행 데이터를 전달" },
{ key: "all_rows", label: "전체 데이터", type: "all_rows", category: "data", description: "필터 적용 전 전체 데이터 배열 (상태 칩 건수 등)" },
{ key: "cart_updated", label: "장바구니 상태", type: "event", category: "event", description: "장바구니 변경 시 count/isDirty 전달" },
{ key: "cart_save_completed", label: "저장 완료", type: "event", category: "event", description: "장바구니 DB 저장 완료 후 결과 전달" },
{ key: "selected_items", label: "선택된 항목", type: "event", category: "event", description: "장바구니 모드에서 체크박스로 선택된 항목 배열" },
{ key: "collected_data", label: "수집 응답", type: "event", category: "event", description: "데이터 수집 요청에 대한 응답 (선택 항목 + 매핑)" },
],
receivable: [
{ key: "filter_condition", label: "필터 조건", type: "filter_value", category: "filter", description: "외부 컴포넌트에서 받은 필터 조건으로 카드 목록 필터링" },
{ key: "cart_save_trigger", label: "저장 요청", type: "event", category: "event", description: "장바구니 DB 일괄 저장 트리거 (버튼에서 수신)" },
{ key: "confirm_trigger", label: "확정 트리거", type: "event", category: "event", description: "입고 확정 시 수정된 수량 일괄 반영 + 선택 항목 전달" },
{ key: "collect_data", label: "수집 요청", type: "event", category: "event", description: "버튼에서 데이터+매핑 수집 요청 수신" },
],
},
touchOptimized: true,
supportedDevices: ["mobile", "tablet"],
});
@@ -0,0 +1,163 @@
/**
* pop-card-list v1 -> v2
*
* PopCardListConfig의 (/////)
* CardGridConfigV2 PopCardListV2Config를 .
*/
import type {
PopCardListConfig,
PopCardListV2Config,
CardCellDefinitionV2,
} from "../types";
export function migrateCardListConfig(old: PopCardListConfig): PopCardListV2Config {
const cells: CardCellDefinitionV2[] = [];
let nextRow = 1;
// 1. 헤더 행 (코드 + 제목)
if (old.cardTemplate?.header?.codeField || old.cardTemplate?.header?.titleField) {
if (old.cardTemplate.header.codeField) {
cells.push({
id: "h-code",
row: nextRow,
col: 1,
rowSpan: 1,
colSpan: 1,
type: "text",
columnName: old.cardTemplate.header.codeField,
fontSize: "sm",
textColor: "hsl(var(--muted-foreground))",
});
}
if (old.cardTemplate.header.titleField) {
cells.push({
id: "h-title",
row: nextRow,
col: 2,
rowSpan: 1,
colSpan: old.cardTemplate.header.codeField ? 2 : 3,
type: "text",
columnName: old.cardTemplate.header.titleField,
fontSize: "md",
fontWeight: "bold",
});
}
nextRow++;
}
// 2. 이미지 (왼쪽, 본문 높이만큼 rowSpan)
const bodyFieldCount = old.cardTemplate?.body?.fields?.length || 0;
const bodyRowSpan = Math.max(1, bodyFieldCount);
if (old.cardTemplate?.image?.enabled) {
cells.push({
id: "img",
row: nextRow,
col: 1,
rowSpan: bodyRowSpan,
colSpan: 1,
type: "image",
columnName: old.cardTemplate.image.imageColumn || "",
defaultImage: old.cardTemplate.image.defaultImage,
});
}
// 3. 본문 필드들 (이미지 오른쪽)
const fieldStartCol = old.cardTemplate?.image?.enabled ? 2 : 1;
const fieldColSpan = old.cardTemplate?.image?.enabled ? 2 : 3;
const hasRightActions = !!(old.inputField?.enabled || old.cartAction);
(old.cardTemplate?.body?.fields || []).forEach((field, i) => {
cells.push({
id: `f-${i}`,
row: nextRow + i,
col: fieldStartCol,
rowSpan: 1,
colSpan: hasRightActions ? fieldColSpan - 1 : fieldColSpan,
type: "field",
columnName: field.columnName,
label: field.label,
valueType: field.valueType,
formulaLeft: field.formulaLeft,
formulaOperator: field.formulaOperator as CardCellDefinitionV2["formulaOperator"],
formulaRight: field.formulaRight,
formulaRightType: field.formulaRightType as CardCellDefinitionV2["formulaRightType"],
unit: field.unit,
textColor: field.textColor,
});
});
// 4. 수량 입력 + 담기 버튼 (오른쪽 열)
const rightCol = 3;
if (old.inputField?.enabled) {
cells.push({
id: "input",
row: nextRow,
col: rightCol,
rowSpan: Math.ceil(bodyRowSpan / 2),
colSpan: 1,
type: "number-input",
inputUnit: old.inputField.unit,
limitColumn: old.inputField.limitColumn || old.inputField.maxColumn,
});
}
if (old.cartAction) {
cells.push({
id: "cart",
row: nextRow + Math.ceil(bodyRowSpan / 2),
col: rightCol,
rowSpan: Math.floor(bodyRowSpan / 2) || 1,
colSpan: 1,
type: "cart-button",
cartLabel: old.cartAction.label,
cartCancelLabel: old.cartAction.cancelLabel,
cartIconType: old.cartAction.iconType,
cartIconValue: old.cartAction.iconValue,
});
}
// 5. 포장 요약 (마지막 행, full-width)
if (old.packageConfig?.enabled) {
const summaryRow = nextRow + bodyRowSpan;
cells.push({
id: "pkg",
row: summaryRow,
col: 1,
rowSpan: 1,
colSpan: 3,
type: "package-summary",
});
}
// 그리드 크기 계산
const maxRow = cells.length > 0 ? Math.max(...cells.map((c) => c.row + c.rowSpan - 1)) : 1;
const maxCol = 3;
return {
dataSource: old.dataSource,
cardGrid: {
rows: maxRow,
cols: maxCol,
colWidths: old.cardTemplate?.image?.enabled
? ["1fr", "2fr", "1fr"]
: ["1fr", "2fr", "1fr"],
gap: 2,
showCellBorder: false,
cells,
},
scrollDirection: old.scrollDirection,
cardSize: old.cardSize,
gridColumns: old.gridColumns,
gridRows: old.gridRows,
cardGap: 8,
overflow: { mode: "loadMore", visibleCount: 6, loadMoreCount: 6 },
cardClickAction: "none",
responsiveDisplay: old.responsiveDisplay,
inputField: old.inputField,
packageConfig: old.packageConfig,
cartAction: old.cartAction,
cartListMode: old.cartListMode,
saveMapping: old.saveMapping,
};
}
@@ -256,6 +256,12 @@ export function PopCardListComponent({
return unsub;
}, [componentId, subscribe]);
// 전체 rows 발행 (status-chip 등 연결된 컴포넌트에서 건수 집계용)
useEffect(() => {
if (!componentId || loading) return;
publish(`__comp_output__${componentId}__all_rows`, rows);
}, [componentId, rows, loading, publish]);
// cart를 ref로 유지: 이벤트 콜백에서 항상 최신 참조를 사용
const cartRef = useRef(cart);
cartRef.current = cart;
@@ -2039,16 +2039,29 @@ function FilterSettingsSection({
{filters.map((filter, index) => (
<div
key={index}
className="flex items-center gap-1 rounded-md border bg-card p-1.5"
className="space-y-1.5 rounded-md border bg-card p-2"
>
<div className="flex items-center justify-between">
<span className="text-[10px] font-medium text-muted-foreground">
{index + 1}
</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-destructive"
onClick={() => deleteFilter(index)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
<Select
value={filter.column || ""}
onValueChange={(val) =>
updateFilter(index, { ...filter, column: val })
}
>
<SelectTrigger className="h-7 text-xs flex-1">
<SelectValue placeholder="컬럼" />
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{columns.map((col) => (
@@ -2058,45 +2071,36 @@ function FilterSettingsSection({
))}
</SelectContent>
</Select>
<Select
value={filter.operator}
onValueChange={(val) =>
updateFilter(index, {
...filter,
operator: val as FilterOperator,
})
}
>
<SelectTrigger className="h-7 w-16 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{operators.map((op) => (
<SelectItem key={op.value} value={op.value}>
{op.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
value={filter.value}
onChange={(e) =>
updateFilter(index, { ...filter, value: e.target.value })
}
placeholder=""
className="h-7 flex-1 text-xs"
/>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0 text-destructive"
onClick={() => deleteFilter(index)}
>
<Trash2 className="h-3 w-3" />
</Button>
<div className="flex items-center gap-2">
<Select
value={filter.operator}
onValueChange={(val) =>
updateFilter(index, {
...filter,
operator: val as FilterOperator,
})
}
>
<SelectTrigger className="h-8 w-20 shrink-0 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{operators.map((op) => (
<SelectItem key={op.value} value={op.value}>
{op.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
value={filter.value}
onChange={(e) =>
updateFilter(index, { ...filter, value: e.target.value })
}
placeholder="값 입력"
className="h-8 flex-1 text-xs"
/>
</div>
</div>
))}
</div>
@@ -2663,46 +2667,51 @@ function FilterCriteriaSection({
) : (
<div className="space-y-2">
{filters.map((filter, index) => (
<div key={index} className="flex items-center gap-1 rounded-md border bg-card p-1.5">
<div className="flex-1">
<GroupedColumnSelect
columnGroups={columnGroups}
value={filter.column || undefined}
onValueChange={(val) => updateFilter(index, { ...filter, column: val || "" })}
placeholder="컬럼 선택"
<div key={index} className="space-y-1.5 rounded-md border bg-card p-2">
<div className="flex items-center justify-between">
<span className="text-[10px] font-medium text-muted-foreground">
{index + 1}
</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-destructive"
onClick={() => deleteFilter(index)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
<GroupedColumnSelect
columnGroups={columnGroups}
value={filter.column || undefined}
onValueChange={(val) => updateFilter(index, { ...filter, column: val || "" })}
placeholder="컬럼 선택"
/>
<div className="flex items-center gap-2">
<Select
value={filter.operator}
onValueChange={(val) =>
updateFilter(index, { ...filter, operator: val as FilterOperator })
}
>
<SelectTrigger className="h-8 w-20 shrink-0 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FILTER_OPERATORS.map((op) => (
<SelectItem key={op.value} value={op.value}>
{op.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
value={filter.value}
onChange={(e) => updateFilter(index, { ...filter, value: e.target.value })}
placeholder="값 입력"
className="h-8 flex-1 text-xs"
/>
</div>
<Select
value={filter.operator}
onValueChange={(val) =>
updateFilter(index, { ...filter, operator: val as FilterOperator })
}
>
<SelectTrigger className="h-7 w-16 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FILTER_OPERATORS.map((op) => (
<SelectItem key={op.value} value={op.value}>
{op.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
value={filter.value}
onChange={(e) => updateFilter(index, { ...filter, value: e.target.value })}
placeholder="값"
className="h-7 flex-1 text-xs"
/>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0 text-destructive"
onClick={() => deleteFilter(index)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
</div>
@@ -61,6 +61,7 @@ PopComponentRegistry.registerComponent({
connectionMeta: {
sendable: [
{ key: "selected_row", label: "선택된 행", type: "selected_row", category: "data", description: "사용자가 선택한 카드의 행 데이터를 전달" },
{ key: "all_rows", label: "전체 데이터", type: "all_rows", category: "data", description: "필터 적용 전 전체 데이터 배열 (상태 칩 건수 등)" },
{ key: "cart_updated", label: "장바구니 상태", type: "event", category: "event", description: "장바구니 변경 시 count/isDirty 전달" },
{ key: "cart_save_completed", label: "저장 완료", type: "event", category: "event", description: "장바구니 DB 저장 완료 후 결과 전달" },
{ key: "selected_items", label: "선택된 항목", type: "event", category: "event", description: "장바구니 모드에서 체크박스로 선택된 항목 배열" },
@@ -34,6 +34,7 @@ export interface ColumnInfo {
type: string;
udtName: string;
isPrimaryKey?: boolean;
comment?: string;
}
// ===== SQL 값 이스케이프 =====
@@ -330,6 +331,7 @@ export async function fetchTableColumns(
type: col.dataType || col.data_type || col.type || "unknown",
udtName: col.dbType || col.udt_name || col.udtName || "unknown",
isPrimaryKey: col.isPrimaryKey === true || col.isPrimaryKey === "true" || col.is_primary_key === true || col.is_primary_key === "true",
comment: col.columnComment || col.description || "",
}));
}
}
@@ -203,6 +203,32 @@ export function PopFieldComponent({
return unsub;
}, [componentId, subscribe, cfg.readSource, fetchReadSourceData]);
useEffect(() => {
const unsub = subscribe("scan_auto_fill", (payload: unknown) => {
const data = payload as Record<string, unknown> | null;
if (!data || typeof data !== "object") return;
const fieldNames = new Set<string>();
for (const section of cfg.sections) {
for (const f of section.fields ?? []) {
if (f.fieldName) fieldNames.add(f.fieldName);
}
}
const matched: Record<string, unknown> = {};
for (const [key, value] of Object.entries(data)) {
if (fieldNames.has(key)) {
matched[key] = value;
}
}
if (Object.keys(matched).length > 0) {
setAllValues((prev) => ({ ...prev, ...matched }));
}
});
return unsub;
}, [subscribe, cfg.sections]);
// 데이터 수집 요청 수신: 버튼에서 collect_data 요청 → allValues + saveConfig 응답
useEffect(() => {
if (!componentId) return;
@@ -220,7 +246,7 @@ export function PopFieldComponent({
? {
targetTable: cfg.saveConfig.tableName,
columnMapping: Object.fromEntries(
(cfg.saveConfig.fieldMappings || []).map((m) => [m.fieldId, m.targetColumn])
(cfg.saveConfig.fieldMappings || []).map((m) => [fieldIdToName[m.fieldId] || m.fieldId, m.targetColumn])
),
autoGenMappings: (cfg.saveConfig.autoGenMappings || [])
.filter((m) => m.numberingRuleId)
@@ -228,6 +254,7 @@ export function PopFieldComponent({
numberingRuleId: m.numberingRuleId!,
targetColumn: m.targetColumn,
showResultModal: m.showResultModal,
shareAcrossItems: m.shareAcrossItems ?? false,
})),
hiddenMappings: (cfg.saveConfig.hiddenMappings || [])
.filter((m) => m.targetColumn)
@@ -247,7 +274,7 @@ export function PopFieldComponent({
}
);
return unsub;
}, [componentId, subscribe, publish, allValues, cfg.saveConfig]);
}, [componentId, subscribe, publish, allValues, cfg.saveConfig, fieldIdToName]);
// 필드 값 변경 핸들러
const handleFieldChange = useCallback(
@@ -398,8 +398,19 @@ function SaveTabContent({
syncAndUpdateSaveMappings((prev) =>
prev.map((m) => (m.fieldId === fieldId ? { ...m, ...partial } : m))
);
if (partial.targetColumn !== undefined) {
const newFieldName = partial.targetColumn || "";
const sections = cfg.sections.map((s) => ({
...s,
fields: (s.fields ?? []).map((f) =>
f.id === fieldId ? { ...f, fieldName: newFieldName } : f
),
}));
onUpdateConfig({ sections });
}
},
[syncAndUpdateSaveMappings]
[syncAndUpdateSaveMappings, cfg, onUpdateConfig]
);
// --- 숨은 필드 매핑 로직 ---
@@ -1337,7 +1348,19 @@ function SaveTabContent({
/>
<Label className="text-[10px]"> </Label>
</div>
<div className="flex items-center gap-1.5">
<Switch
checked={m.shareAcrossItems ?? false}
onCheckedChange={(v) => updateAutoGenMapping(m.id, { shareAcrossItems: v })}
/>
<Label className="text-[10px]"> </Label>
</div>
</div>
{m.shareAcrossItems && (
<p className="text-[9px] text-muted-foreground">
</p>
)}
</div>
);
})}
@@ -1414,7 +1437,7 @@ function SectionEditor({
const newField: PopFieldItem = {
id: fieldId,
inputType: "text",
fieldName: fieldId,
fieldName: "",
labelText: "",
readOnly: false,
};
@@ -153,6 +153,7 @@ export interface PopFieldAutoGenMapping {
numberingRuleId?: string;
showInForm: boolean;
showResultModal: boolean;
shareAcrossItems?: boolean;
}
export interface PopFieldSaveConfig {
@@ -0,0 +1,336 @@
"use client";
import React, { useState, useMemo } from "react";
import { useRouter } from "next/navigation";
import { cn } from "@/lib/utils";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Monitor, LayoutGrid, LogOut, UserCircle } from "lucide-react";
import { PopComponentRegistry } from "@/lib/registry/PopComponentRegistry";
import { useAuth } from "@/hooks/useAuth";
// ========================================
// 타입 정의
// ========================================
type AvatarSize = "sm" | "md" | "lg";
export interface PopProfileConfig {
avatarSize?: AvatarSize;
showDashboardLink?: boolean;
showPcMode?: boolean;
showLogout?: boolean;
}
const DEFAULT_CONFIG: PopProfileConfig = {
avatarSize: "md",
showDashboardLink: true,
showPcMode: true,
showLogout: true,
};
const AVATAR_SIZE_MAP: Record<AvatarSize, { container: string; text: string; px: number }> = {
sm: { container: "h-8 w-8", text: "text-sm", px: 32 },
md: { container: "h-10 w-10", text: "text-base", px: 40 },
lg: { container: "h-12 w-12", text: "text-lg", px: 48 },
};
const AVATAR_SIZE_LABELS: Record<AvatarSize, string> = {
sm: "작은 (32px)",
md: "보통 (40px)",
lg: "큰 (48px)",
};
// ========================================
// 뷰어 컴포넌트
// ========================================
interface PopProfileComponentProps {
config?: PopProfileConfig;
componentId?: string;
screenId?: string;
}
function PopProfileComponent({ config: rawConfig }: PopProfileComponentProps) {
const router = useRouter();
const { user, isLoggedIn, logout } = useAuth();
const [open, setOpen] = useState(false);
const config = useMemo(() => ({
...DEFAULT_CONFIG,
...rawConfig,
}), [rawConfig]);
const sizeInfo = AVATAR_SIZE_MAP[config.avatarSize || "md"];
const initial = user?.userName?.substring(0, 1)?.toUpperCase() || "?";
const handlePcMode = () => {
setOpen(false);
router.push("/");
};
const handleDashboard = () => {
setOpen(false);
router.push("/pop");
};
const handleLogout = async () => {
setOpen(false);
await logout();
};
const handleLogin = () => {
setOpen(false);
router.push("/login");
};
return (
<div className="flex h-full w-full items-center justify-center">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
className={cn(
"flex items-center justify-center rounded-full",
"bg-primary text-primary-foreground font-bold",
"border-2 border-primary/20 cursor-pointer",
"transition-all duration-150",
"hover:scale-105 hover:border-primary/40",
"active:scale-95",
sizeInfo.container,
sizeInfo.text,
)}
style={{ minWidth: sizeInfo.px, minHeight: sizeInfo.px }}
>
{user?.photo && user.photo.trim() !== "" && user.photo !== "null" ? (
<img
src={user.photo}
alt={user.userName || "User"}
className="h-full w-full rounded-full object-cover"
/>
) : (
initial
)}
</button>
</PopoverTrigger>
<PopoverContent
className="w-60 p-0"
align="end"
sideOffset={8}
>
{isLoggedIn && user ? (
<>
{/* 사용자 정보 */}
<div className="flex items-center gap-3 border-b p-4">
<div className={cn(
"flex shrink-0 items-center justify-center rounded-full",
"bg-primary text-primary-foreground font-bold",
"h-10 w-10 text-base",
)}>
{user.photo && user.photo.trim() !== "" && user.photo !== "null" ? (
<img
src={user.photo}
alt={user.userName || "User"}
className="h-full w-full rounded-full object-cover"
/>
) : (
initial
)}
</div>
<div className="flex min-w-0 flex-col gap-0.5">
<span className="truncate text-sm font-semibold">
{user.userName || "사용자"} ({user.userId || ""})
</span>
<span className="truncate text-xs text-muted-foreground">
{user.deptName || "부서 정보 없음"}
</span>
</div>
</div>
{/* 메뉴 항목 */}
<div className="p-1.5">
{config.showDashboardLink && (
<button
onClick={handleDashboard}
className="flex w-full items-center gap-3 rounded-md px-3 py-3 text-sm transition-colors hover:bg-accent"
style={{ minHeight: 48 }}
>
<LayoutGrid className="h-4 w-4 text-muted-foreground" />
POP
</button>
)}
{config.showPcMode && (
<button
onClick={handlePcMode}
className="flex w-full items-center gap-3 rounded-md px-3 py-3 text-sm transition-colors hover:bg-accent"
style={{ minHeight: 48 }}
>
<Monitor className="h-4 w-4 text-muted-foreground" />
PC
</button>
)}
{config.showLogout && (
<>
<div className="mx-2 my-1 border-t" />
<button
onClick={handleLogout}
className="flex w-full items-center gap-3 rounded-md px-3 py-3 text-sm text-destructive transition-colors hover:bg-destructive/10"
style={{ minHeight: 48 }}
>
<LogOut className="h-4 w-4" />
</button>
</>
)}
</div>
</>
) : (
<div className="p-4">
<p className="mb-3 text-center text-sm text-muted-foreground">
</p>
<button
onClick={handleLogin}
className="flex w-full items-center justify-center gap-2 rounded-md bg-primary px-3 py-3 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
style={{ minHeight: 48 }}
>
</button>
</div>
)}
</PopoverContent>
</Popover>
</div>
);
}
// ========================================
// 설정 패널
// ========================================
interface PopProfileConfigPanelProps {
config: PopProfileConfig;
onUpdate: (config: PopProfileConfig) => void;
}
function PopProfileConfigPanel({ config: rawConfig, onUpdate }: PopProfileConfigPanelProps) {
const config = useMemo(() => ({
...DEFAULT_CONFIG,
...rawConfig,
}), [rawConfig]);
const updateConfig = (partial: Partial<PopProfileConfig>) => {
onUpdate({ ...config, ...partial });
};
return (
<div className="space-y-4 p-3">
{/* 아바타 크기 */}
<div className="space-y-1.5">
<Label className="text-xs sm:text-sm"> </Label>
<Select
value={config.avatarSize || "md"}
onValueChange={(v) => updateConfig({ avatarSize: v as AvatarSize })}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.entries(AVATAR_SIZE_LABELS) as [AvatarSize, string][]).map(([key, label]) => (
<SelectItem key={key} value={key} className="text-xs sm:text-sm">
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 메뉴 항목 토글 */}
<div className="space-y-3">
<Label className="text-xs sm:text-sm"> </Label>
<div className="flex items-center justify-between">
<Label className="text-xs text-muted-foreground">POP </Label>
<Switch
checked={config.showDashboardLink ?? true}
onCheckedChange={(v) => updateConfig({ showDashboardLink: v })}
/>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs text-muted-foreground">PC </Label>
<Switch
checked={config.showPcMode ?? true}
onCheckedChange={(v) => updateConfig({ showPcMode: v })}
/>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs text-muted-foreground"></Label>
<Switch
checked={config.showLogout ?? true}
onCheckedChange={(v) => updateConfig({ showLogout: v })}
/>
</div>
</div>
</div>
);
}
// ========================================
// 디자이너 미리보기
// ========================================
function PopProfilePreview({ config }: { config?: PopProfileConfig }) {
const size = AVATAR_SIZE_MAP[config?.avatarSize || "md"];
return (
<div className="flex h-full w-full items-center justify-center gap-2">
<div className={cn(
"flex items-center justify-center rounded-full",
"bg-primary/20 text-primary",
size.container, size.text,
)}>
<UserCircle className="h-5 w-5" />
</div>
<span className="text-xs text-muted-foreground"></span>
</div>
);
}
// ========================================
// 레지스트리 등록
// ========================================
PopComponentRegistry.registerComponent({
id: "pop-profile",
name: "프로필",
description: "사용자 프로필 / PC 전환 / 로그아웃",
category: "action",
icon: "UserCircle",
component: PopProfileComponent,
configPanel: PopProfileConfigPanel,
preview: PopProfilePreview,
defaultProps: {
avatarSize: "md",
showDashboardLink: true,
showPcMode: true,
showLogout: true,
},
connectionMeta: {
sendable: [],
receivable: [],
},
touchOptimized: true,
supportedDevices: ["mobile", "tablet"],
});
@@ -0,0 +1,694 @@
"use client";
import React, { useState, useCallback, useMemo, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Checkbox } from "@/components/ui/checkbox";
import { ScanLine } from "lucide-react";
import { PopComponentRegistry } from "@/lib/registry/PopComponentRegistry";
import { usePopEvent } from "@/hooks/pop/usePopEvent";
import { BarcodeScanModal } from "@/components/common/BarcodeScanModal";
import type {
PopDataConnection,
PopComponentDefinitionV5,
} from "@/components/pop/designer/types/pop-layout";
// ========================================
// 타입 정의
// ========================================
export interface ScanFieldMapping {
sourceKey: string;
outputIndex: number;
label: string;
targetComponentId: string;
targetFieldName: string;
enabled: boolean;
}
export interface PopScannerConfig {
barcodeFormat: "all" | "1d" | "2d";
autoSubmit: boolean;
showLastScan: boolean;
buttonLabel: string;
buttonVariant: "default" | "outline" | "secondary";
parseMode: "none" | "auto" | "json";
fieldMappings: ScanFieldMapping[];
}
// 연결된 컴포넌트의 필드 정보
interface ConnectedFieldInfo {
componentId: string;
componentName: string;
componentType: string;
fieldName: string;
fieldLabel: string;
}
const DEFAULT_SCANNER_CONFIG: PopScannerConfig = {
barcodeFormat: "all",
autoSubmit: true,
showLastScan: false,
buttonLabel: "스캔",
buttonVariant: "default",
parseMode: "none",
fieldMappings: [],
};
// ========================================
// 파싱 유틸리티
// ========================================
function tryParseJson(raw: string): Record<string, string> | null {
try {
const parsed = JSON.parse(raw);
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
const result: Record<string, string> = {};
for (const [k, v] of Object.entries(parsed)) {
result[k] = String(v);
}
return result;
}
} catch {
// JSON이 아닌 경우
}
return null;
}
function parseScanResult(
raw: string,
mode: PopScannerConfig["parseMode"]
): Record<string, string> | null {
if (mode === "none") return null;
return tryParseJson(raw);
}
// ========================================
// 연결된 컴포넌트 필드 추출
// ========================================
function getConnectedFields(
componentId?: string,
connections?: PopDataConnection[],
allComponents?: PopComponentDefinitionV5[],
): ConnectedFieldInfo[] {
if (!componentId || !connections || !allComponents) return [];
const targetIds = connections
.filter((c) => c.sourceComponent === componentId)
.map((c) => c.targetComponent);
const uniqueTargetIds = [...new Set(targetIds)];
const fields: ConnectedFieldInfo[] = [];
for (const tid of uniqueTargetIds) {
const comp = allComponents.find((c) => c.id === tid);
if (!comp?.config) continue;
const compCfg = comp.config as Record<string, unknown>;
const compType = comp.type || "";
const compName = (comp as Record<string, unknown>).label as string || comp.type || tid;
// pop-search: filterColumns (복수) 또는 modalConfig.valueField 또는 fieldName (단일)
const filterCols = compCfg.filterColumns as string[] | undefined;
const modalCfg = compCfg.modalConfig as { valueField?: string; displayField?: string } | undefined;
if (Array.isArray(filterCols) && filterCols.length > 0) {
for (const col of filterCols) {
fields.push({
componentId: tid,
componentName: compName,
componentType: compType,
fieldName: col,
fieldLabel: col,
});
}
} else if (modalCfg?.valueField) {
fields.push({
componentId: tid,
componentName: compName,
componentType: compType,
fieldName: modalCfg.valueField,
fieldLabel: (compCfg.placeholder as string) || modalCfg.valueField,
});
} else if (compCfg.fieldName && typeof compCfg.fieldName === "string") {
fields.push({
componentId: tid,
componentName: compName,
componentType: compType,
fieldName: compCfg.fieldName,
fieldLabel: (compCfg.placeholder as string) || compCfg.fieldName as string,
});
}
// pop-field: sections 내 fields
const sections = compCfg.sections as Array<{
fields?: Array<{ id: string; fieldName?: string; labelText?: string }>;
}> | undefined;
if (Array.isArray(sections)) {
for (const section of sections) {
for (const f of section.fields ?? []) {
if (f.fieldName) {
fields.push({
componentId: tid,
componentName: compName,
componentType: compType,
fieldName: f.fieldName,
fieldLabel: f.labelText || f.fieldName,
});
}
}
}
}
}
return fields;
}
// ========================================
// 메인 컴포넌트
// ========================================
interface PopScannerComponentProps {
config?: PopScannerConfig;
label?: string;
isDesignMode?: boolean;
screenId?: string;
componentId?: string;
}
function PopScannerComponent({
config,
isDesignMode,
screenId,
componentId,
}: PopScannerComponentProps) {
const cfg = { ...DEFAULT_SCANNER_CONFIG, ...(config || {}) };
const { publish } = usePopEvent(screenId || "");
const [lastScan, setLastScan] = useState("");
const [modalOpen, setModalOpen] = useState(false);
const handleScanSuccess = useCallback(
(barcode: string) => {
setLastScan(barcode);
setModalOpen(false);
if (!componentId) return;
if (cfg.parseMode === "none") {
publish(`__comp_output__${componentId}__scan_value`, barcode);
return;
}
const parsed = parseScanResult(barcode, cfg.parseMode);
if (!parsed) {
publish(`__comp_output__${componentId}__scan_value`, barcode);
return;
}
if (cfg.parseMode === "auto") {
publish("scan_auto_fill", parsed);
publish(`__comp_output__${componentId}__scan_value`, barcode);
return;
}
if (cfg.fieldMappings.length === 0) {
publish(`__comp_output__${componentId}__scan_value`, barcode);
return;
}
for (const mapping of cfg.fieldMappings) {
if (!mapping.enabled) continue;
const value = parsed[mapping.sourceKey];
if (value === undefined) continue;
publish(
`__comp_output__${componentId}__scan_field_${mapping.outputIndex}`,
value
);
if (mapping.targetComponentId && mapping.targetFieldName) {
publish(
`__comp_input__${mapping.targetComponentId}__set_value`,
{ fieldName: mapping.targetFieldName, value }
);
}
}
},
[componentId, publish, cfg.parseMode, cfg.fieldMappings],
);
const handleClick = useCallback(() => {
if (isDesignMode) return;
setModalOpen(true);
}, [isDesignMode]);
return (
<div className="flex h-full w-full items-center justify-center">
<Button
variant={cfg.buttonVariant}
size="icon"
onClick={handleClick}
className="h-full w-full rounded-md transition-transform active:scale-95"
>
<ScanLine className="h-7! w-7!" />
<span className="sr-only">{cfg.buttonLabel}</span>
</Button>
{cfg.showLastScan && lastScan && (
<div className="absolute inset-x-0 bottom-0 truncate bg-background/80 px-1 text-center text-[8px] text-muted-foreground backdrop-blur-sm">
{lastScan}
</div>
)}
{!isDesignMode && (
<BarcodeScanModal
open={modalOpen}
onOpenChange={setModalOpen}
barcodeFormat={cfg.barcodeFormat}
autoSubmit={cfg.autoSubmit}
onScanSuccess={handleScanSuccess}
/>
)}
</div>
);
}
// ========================================
// 설정 패널
// ========================================
const FORMAT_LABELS: Record<string, string> = {
all: "모든 형식",
"1d": "1D 바코드",
"2d": "2D 바코드 (QR)",
};
const VARIANT_LABELS: Record<string, string> = {
default: "기본 (Primary)",
outline: "외곽선 (Outline)",
secondary: "보조 (Secondary)",
};
const PARSE_MODE_LABELS: Record<string, string> = {
none: "없음 (단일 값)",
auto: "자동 (검색 필드명과 매칭)",
json: "JSON (수동 매핑)",
};
interface PopScannerConfigPanelProps {
config: PopScannerConfig;
onUpdate: (config: PopScannerConfig) => void;
allComponents?: PopComponentDefinitionV5[];
connections?: PopDataConnection[];
componentId?: string;
}
function PopScannerConfigPanel({
config,
onUpdate,
allComponents,
connections,
componentId,
}: PopScannerConfigPanelProps) {
const cfg = { ...DEFAULT_SCANNER_CONFIG, ...config };
const update = (partial: Partial<PopScannerConfig>) => {
onUpdate({ ...cfg, ...partial });
};
const connectedFields = useMemo(
() => getConnectedFields(componentId, connections, allComponents),
[componentId, connections, allComponents],
);
const buildMappingsFromFields = useCallback(
(fields: ConnectedFieldInfo[], existing: ScanFieldMapping[]): ScanFieldMapping[] => {
return fields.map((f, i) => {
const prev = existing.find(
(m) => m.targetComponentId === f.componentId && m.targetFieldName === f.fieldName
);
return {
sourceKey: prev?.sourceKey ?? f.fieldName,
outputIndex: i,
label: f.fieldLabel,
targetComponentId: f.componentId,
targetFieldName: f.fieldName,
enabled: prev?.enabled ?? true,
};
});
},
[],
);
const toggleMapping = (fieldName: string, componentId: string) => {
const updated = cfg.fieldMappings.map((m) =>
m.targetFieldName === fieldName && m.targetComponentId === componentId
? { ...m, enabled: !m.enabled }
: m
);
update({ fieldMappings: updated });
};
const updateMappingSourceKey = (fieldName: string, componentId: string, sourceKey: string) => {
const updated = cfg.fieldMappings.map((m) =>
m.targetFieldName === fieldName && m.targetComponentId === componentId
? { ...m, sourceKey }
: m
);
update({ fieldMappings: updated });
};
useEffect(() => {
if (cfg.parseMode !== "json" || connectedFields.length === 0) return;
const synced = buildMappingsFromFields(connectedFields, cfg.fieldMappings);
const isSame =
synced.length === cfg.fieldMappings.length &&
synced.every(
(s, i) =>
s.targetComponentId === cfg.fieldMappings[i]?.targetComponentId &&
s.targetFieldName === cfg.fieldMappings[i]?.targetFieldName,
);
if (!isSame) {
onUpdate({ ...cfg, fieldMappings: synced });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [connectedFields, cfg.parseMode]);
return (
<div className="space-y-4 pr-1 pb-16">
<div className="space-y-1.5">
<Label className="text-xs"> </Label>
<Select
value={cfg.barcodeFormat}
onValueChange={(v) => update({ barcodeFormat: v as PopScannerConfig["barcodeFormat"] })}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(FORMAT_LABELS).map(([key, label]) => (
<SelectItem key={key} value={key} className="text-xs">
{label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[10px] text-muted-foreground"> </p>
</div>
<div className="space-y-1.5">
<Label className="text-xs"> </Label>
<Input
value={cfg.buttonLabel}
onChange={(e) => update({ buttonLabel: e.target.value })}
placeholder="스캔"
className="h-8 text-xs"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs"> </Label>
<Select
value={cfg.buttonVariant}
onValueChange={(v) => update({ buttonVariant: v as PopScannerConfig["buttonVariant"] })}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(VARIANT_LABELS).map(([key, label]) => (
<SelectItem key={key} value={key} className="text-xs">
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between">
<div>
<Label className="text-xs"> </Label>
<p className="text-[10px] text-muted-foreground">
{cfg.autoSubmit
? "바코드 인식 즉시 값 전달 (확인 버튼 생략)"
: "인식 후 확인 버튼을 눌러야 값 전달"}
</p>
</div>
<Switch
checked={cfg.autoSubmit}
onCheckedChange={(v) => update({ autoSubmit: v })}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label className="text-xs"> </Label>
<p className="text-[10px] text-muted-foreground"> </p>
</div>
<Switch
checked={cfg.showLastScan}
onCheckedChange={(v) => update({ showLastScan: v })}
/>
</div>
{/* 파싱 설정 섹션 */}
<div className="border-t pt-4">
<Label className="text-xs font-semibold"> </Label>
<p className="mb-3 text-[10px] text-muted-foreground">
/QR에 ,
</p>
<div className="space-y-1.5">
<Label className="text-xs"> </Label>
<Select
value={cfg.parseMode}
onValueChange={(v) => {
const mode = v as PopScannerConfig["parseMode"];
update({
parseMode: mode,
fieldMappings: mode === "none" ? [] : cfg.fieldMappings,
});
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(PARSE_MODE_LABELS).map(([key, label]) => (
<SelectItem key={key} value={key} className="text-xs">
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{cfg.parseMode === "auto" && (
<div className="mt-3 rounded-md bg-muted/50 p-3">
<p className="text-[10px] font-medium"> </p>
<p className="mt-1 text-[10px] text-muted-foreground">
QR/ JSON .
</p>
{connectedFields.length > 0 && (
<div className="mt-2 space-y-1">
<p className="text-[10px] font-medium"> :</p>
{connectedFields.map((f, i) => (
<div key={i} className="flex items-center gap-1 text-[10px] text-muted-foreground">
<span className="font-mono text-primary">{f.fieldName}</span>
<span>- {f.fieldLabel}</span>
<span className="text-muted-foreground/50">({f.componentName})</span>
</div>
))}
<p className="mt-1 text-[10px] text-muted-foreground">
QR에 JSON .
</p>
</div>
)}
{connectedFields.length === 0 && (
<p className="mt-2 text-[10px] text-muted-foreground">
.
.
</p>
)}
</div>
)}
{cfg.parseMode === "json" && (
<div className="mt-3 space-y-3">
<p className="text-[10px] text-muted-foreground">
, JSON .
JSON .
</p>
{connectedFields.length === 0 ? (
<div className="rounded-md bg-muted/50 p-3">
<p className="text-[10px] text-muted-foreground">
.
.
</p>
</div>
) : (
<div className="space-y-2">
<Label className="text-xs font-semibold"> </Label>
<div className="space-y-1.5">
{cfg.fieldMappings.map((mapping) => (
<div
key={`${mapping.targetComponentId}_${mapping.targetFieldName}`}
className="flex items-start gap-2 rounded-md border p-2"
>
<Checkbox
id={`map_${mapping.targetComponentId}_${mapping.targetFieldName}`}
checked={mapping.enabled}
onCheckedChange={() =>
toggleMapping(mapping.targetFieldName, mapping.targetComponentId)
}
className="mt-0.5"
/>
<div className="flex-1 space-y-1">
<label
htmlFor={`map_${mapping.targetComponentId}_${mapping.targetFieldName}`}
className="flex cursor-pointer items-center gap-1 text-[11px]"
>
<span className="font-mono text-primary">
{mapping.targetFieldName}
</span>
<span className="text-muted-foreground">
({mapping.label})
</span>
</label>
{mapping.enabled && (
<div className="flex items-center gap-1">
<span className="shrink-0 text-[10px] text-muted-foreground">
JSON :
</span>
<Input
value={mapping.sourceKey}
onChange={(e) =>
updateMappingSourceKey(
mapping.targetFieldName,
mapping.targetComponentId,
e.target.value,
)
}
placeholder={mapping.targetFieldName}
className="h-6 text-[10px]"
/>
</div>
)}
</div>
</div>
))}
</div>
{cfg.fieldMappings.some((m) => m.enabled) && (
<div className="rounded-md bg-muted/50 p-2">
<p className="text-[10px] font-medium text-muted-foreground"> :</p>
<ul className="mt-1 space-y-0.5">
{cfg.fieldMappings
.filter((m) => m.enabled)
.map((m, i) => (
<li key={i} className="text-[10px] text-muted-foreground">
<span className="font-mono">{m.sourceKey || "?"}</span>
{" -> "}
<span className="font-mono text-primary">{m.targetFieldName}</span>
{m.label && <span className="ml-1">({m.label})</span>}
</li>
))}
</ul>
</div>
)}
</div>
)}
</div>
)}
</div>
</div>
);
}
// ========================================
// 미리보기
// ========================================
function PopScannerPreview({ config }: { config?: PopScannerConfig }) {
const cfg = config || DEFAULT_SCANNER_CONFIG;
return (
<div className="flex h-full w-full items-center justify-center overflow-hidden">
<Button
variant={cfg.buttonVariant}
size="icon"
className="pointer-events-none h-full w-full rounded-md"
tabIndex={-1}
>
<ScanLine className="h-7! w-7!" />
</Button>
</div>
);
}
// ========================================
// 동적 sendable 생성
// ========================================
function buildSendableMeta(config?: PopScannerConfig) {
const base = [
{
key: "scan_value",
label: "스캔 값 (원본)",
type: "filter_value" as const,
category: "filter" as const,
description: "파싱 전 원본 스캔 결과 (단일 값 모드이거나 파싱 실패 시)",
},
];
if (config?.fieldMappings && config.fieldMappings.length > 0) {
for (const mapping of config.fieldMappings) {
base.push({
key: `scan_field_${mapping.outputIndex}`,
label: mapping.label || `스캔 필드 ${mapping.outputIndex}`,
type: "filter_value" as const,
category: "filter" as const,
description: `파싱된 필드: JSON 키 "${mapping.sourceKey}"`,
});
}
}
return base;
}
// ========================================
// 레지스트리 등록
// ========================================
PopComponentRegistry.registerComponent({
id: "pop-scanner",
name: "스캐너",
description: "바코드/QR 카메라 스캔",
category: "input",
icon: "ScanLine",
component: PopScannerComponent,
configPanel: PopScannerConfigPanel,
preview: PopScannerPreview,
defaultProps: DEFAULT_SCANNER_CONFIG,
connectionMeta: {
sendable: buildSendableMeta(),
receivable: [],
},
getDynamicConnectionMeta: (config: Record<string, unknown>) => ({
sendable: buildSendableMeta(config as unknown as PopScannerConfig),
receivable: [],
}),
touchOptimized: true,
supportedDevices: ["mobile", "tablet"],
});
@@ -18,12 +18,21 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Switch } from "@/components/ui/switch";
import { Search, ChevronRight, Loader2, X } from "lucide-react";
import { Search, ChevronRight, Loader2, X, CalendarDays } from "lucide-react";
import { Calendar } from "@/components/ui/calendar";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { format, startOfWeek, endOfWeek, startOfMonth, endOfMonth } from "date-fns";
import { ko } from "date-fns/locale";
import { usePopEvent } from "@/hooks/pop";
import { dataApi } from "@/lib/api/data";
import type {
PopSearchConfig,
DatePresetOption,
DateSelectionMode,
ModalSelectConfig,
ModalSearchMode,
ModalFilterTab,
@@ -62,9 +71,21 @@ export function PopSearchComponent({
const [modalDisplayText, setModalDisplayText] = useState("");
const [simpleModalOpen, setSimpleModalOpen] = useState(false);
const fieldKey = config.fieldName || componentId || "search";
const normalizedType = normalizeInputType(config.inputType as string);
const isModalType = normalizedType === "modal";
const fieldKey = isModalType
? (config.modalConfig?.valueField || config.fieldName || componentId || "search")
: (config.fieldName || componentId || "search");
const resolveFilterMode = useCallback(() => {
if (config.filterMode) return config.filterMode;
if (normalizedType === "date") {
const mode: DateSelectionMode = config.dateSelectionMode || "single";
return mode === "range" ? "range" : "equals";
}
return "contains";
}, [config.filterMode, config.dateSelectionMode, normalizedType]);
const emitFilterChanged = useCallback(
(newValue: unknown) => {
@@ -72,15 +93,18 @@ export function PopSearchComponent({
setSharedData(`search_${fieldKey}`, newValue);
if (componentId) {
const filterColumns = config.filterColumns?.length ? config.filterColumns : [fieldKey];
publish(`__comp_output__${componentId}__filter_value`, {
fieldName: fieldKey,
filterColumns,
value: newValue,
filterMode: resolveFilterMode(),
});
}
publish("filter_changed", { [fieldKey]: newValue });
},
[fieldKey, publish, setSharedData, componentId]
[fieldKey, publish, setSharedData, componentId, resolveFilterMode, config.filterColumns]
);
useEffect(() => {
@@ -88,15 +112,40 @@ export function PopSearchComponent({
const unsub = subscribe(
`__comp_input__${componentId}__set_value`,
(payload: unknown) => {
const data = payload as { value?: unknown } | unknown;
const data = payload as { value?: unknown; displayText?: string } | unknown;
const incoming = typeof data === "object" && data && "value" in data
? (data as { value: unknown }).value
: data;
if (isModalType && incoming != null) {
setModalDisplayText(String(incoming));
}
emitFilterChanged(incoming);
}
);
return unsub;
}, [componentId, subscribe, emitFilterChanged]);
}, [componentId, subscribe, emitFilterChanged, isModalType]);
useEffect(() => {
const unsub = subscribe("scan_auto_fill", (payload: unknown) => {
const data = payload as Record<string, unknown> | null;
if (!data || typeof data !== "object") return;
const myKey = config.fieldName;
if (!myKey) return;
const targetKeys = config.filterColumns?.length ? config.filterColumns : [myKey];
for (const key of targetKeys) {
if (key in data) {
if (isModalType) setModalDisplayText(String(data[key]));
emitFilterChanged(data[key]);
return;
}
}
if (myKey in data) {
if (isModalType) setModalDisplayText(String(data[myKey]));
emitFilterChanged(data[myKey]);
}
});
return unsub;
}, [subscribe, emitFilterChanged, config.fieldName, config.filterColumns, isModalType]);
const handleModalOpen = useCallback(() => {
if (!config.modalConfig) return;
@@ -116,29 +165,30 @@ export function PopSearchComponent({
[config.modalConfig, emitFilterChanged]
);
const handleModalClear = useCallback(() => {
setModalDisplayText("");
emitFilterChanged("");
}, [emitFilterChanged]);
const showLabel = config.labelVisible !== false && !!config.labelText;
return (
<div
className={cn(
"flex h-full w-full overflow-hidden",
showLabel && config.labelPosition === "left"
? "flex-row items-center gap-2 p-1.5"
: "flex-col justify-center gap-0.5 p-1.5"
)}
className="flex h-full w-full flex-col items-center justify-center gap-0.5 overflow-hidden p-1.5"
>
{showLabel && (
<span className="shrink-0 truncate text-[10px] font-medium text-muted-foreground">
<span className="w-full shrink-0 truncate text-[10px] font-medium text-muted-foreground">
{config.labelText}
</span>
)}
<div className="min-w-0">
<div className="min-w-0 w-full flex-1 flex flex-col justify-center">
<SearchInputRenderer
config={config}
value={value}
onChange={emitFilterChanged}
modalDisplayText={modalDisplayText}
onModalOpen={handleModalOpen}
onModalClear={handleModalClear}
/>
</div>
@@ -165,9 +215,10 @@ interface InputRendererProps {
onChange: (v: unknown) => void;
modalDisplayText?: string;
onModalOpen?: () => void;
onModalClear?: () => void;
}
function SearchInputRenderer({ config, value, onChange, modalDisplayText, onModalOpen }: InputRendererProps) {
function SearchInputRenderer({ config, value, onChange, modalDisplayText, onModalOpen, onModalClear }: InputRendererProps) {
const normalized = normalizeInputType(config.inputType as string);
switch (normalized) {
case "text":
@@ -175,12 +226,24 @@ function SearchInputRenderer({ config, value, onChange, modalDisplayText, onModa
return <TextSearchInput config={config} value={String(value ?? "")} onChange={onChange} />;
case "select":
return <SelectSearchInput config={config} value={String(value ?? "")} onChange={onChange} />;
case "date": {
const dateMode: DateSelectionMode = config.dateSelectionMode || "single";
return dateMode === "range"
? <DateRangeInput config={config} value={value} onChange={onChange} />
: <DateSingleInput config={config} value={String(value ?? "")} onChange={onChange} />;
}
case "date-preset":
return <DatePresetSearchInput config={config} value={value} onChange={onChange} />;
case "toggle":
return <ToggleSearchInput value={Boolean(value)} onChange={onChange} />;
case "modal":
return <ModalSearchInput config={config} displayText={modalDisplayText || ""} onClick={onModalOpen} />;
return <ModalSearchInput config={config} displayText={modalDisplayText || ""} onClick={onModalOpen} onClear={onModalClear} />;
case "status-chip":
return (
<div className="flex h-full items-center px-2 text-[10px] text-muted-foreground">
pop-status-bar
</div>
);
default:
return <PlaceholderInput inputType={config.inputType} />;
}
@@ -215,7 +278,7 @@ function TextSearchInput({ config, value, onChange }: { config: PopSearchConfig;
const isNumber = config.inputType === "number";
return (
<div className="relative">
<div className="relative h-full">
<Search className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
type={isNumber ? "number" : "text"}
@@ -224,12 +287,283 @@ function TextSearchInput({ config, value, onChange }: { config: PopSearchConfig;
onChange={handleChange}
onKeyDown={handleKeyDown}
placeholder={config.placeholder || (isNumber ? "숫자 입력" : "검색어 입력")}
className="h-8 pl-7 text-xs"
className="h-full min-h-8 pl-7 text-xs"
/>
</div>
);
}
// ========================================
// date 서브타입 - 단일 날짜
// ========================================
function DateSingleInput({ config, value, onChange }: { config: PopSearchConfig; value: string; onChange: (v: unknown) => void }) {
const [open, setOpen] = useState(false);
const useModal = config.calendarDisplay === "modal";
const selected = value ? new Date(value + "T00:00:00") : undefined;
const handleSelect = useCallback(
(day: Date | undefined) => {
if (!day) return;
onChange(format(day, "yyyy-MM-dd"));
setOpen(false);
},
[onChange]
);
const handleClear = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
onChange("");
},
[onChange]
);
const triggerButton = (
<Button
variant="outline"
className={cn(
"h-full min-h-8 w-full justify-start gap-1.5 px-2 text-xs font-normal",
!value && "text-muted-foreground"
)}
onClick={useModal ? () => setOpen(true) : undefined}
>
<CalendarDays className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1 truncate text-left">
{value ? format(new Date(value + "T00:00:00"), "yyyy.MM.dd (EEE)", { locale: ko }) : (config.placeholder || "날짜 선택")}
</span>
{value && (
<span
role="button"
tabIndex={-1}
onClick={handleClear}
onKeyDown={(e) => { if (e.key === "Enter") handleClear(e as unknown as React.MouseEvent); }}
className="shrink-0 text-muted-foreground hover:text-foreground"
>
<X className="h-3 w-3" />
</span>
)}
</Button>
);
if (useModal) {
return (
<>
{triggerButton}
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[360px] p-0">
<DialogHeader className="px-4 pt-4 pb-0">
<DialogTitle className="text-sm"> </DialogTitle>
</DialogHeader>
<div className="flex justify-center pb-4">
<Calendar
mode="single"
selected={selected}
onSelect={handleSelect}
locale={ko}
defaultMonth={selected || new Date()}
className="touch-date-calendar"
/>
</div>
</DialogContent>
</Dialog>
</>
);
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
{triggerButton}
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={selected}
onSelect={handleSelect}
locale={ko}
defaultMonth={selected || new Date()}
/>
</PopoverContent>
</Popover>
);
}
// ========================================
// date 서브타입 - 기간 선택 (프리셋 + Calendar Range)
// ========================================
interface DateRangeValue { from?: string; to?: string }
const RANGE_PRESETS = [
{ key: "today", label: "오늘" },
{ key: "this-week", label: "이번주" },
{ key: "this-month", label: "이번달" },
] as const;
function computeRangePreset(key: string): DateRangeValue {
const now = new Date();
const fmt = (d: Date) => format(d, "yyyy-MM-dd");
switch (key) {
case "today":
return { from: fmt(now), to: fmt(now) };
case "this-week":
return { from: fmt(startOfWeek(now, { weekStartsOn: 1 })), to: fmt(endOfWeek(now, { weekStartsOn: 1 })) };
case "this-month":
return { from: fmt(startOfMonth(now)), to: fmt(endOfMonth(now)) };
default:
return {};
}
}
function DateRangeInput({ config, value, onChange }: { config: PopSearchConfig; value: unknown; onChange: (v: unknown) => void }) {
const [open, setOpen] = useState(false);
const useModal = config.calendarDisplay === "modal";
const rangeVal: DateRangeValue = (typeof value === "object" && value !== null)
? value as DateRangeValue
: (typeof value === "string" && value ? { from: value, to: value } : {});
const calendarRange = useMemo(() => {
if (!rangeVal.from) return undefined;
return {
from: new Date(rangeVal.from + "T00:00:00"),
to: rangeVal.to ? new Date(rangeVal.to + "T00:00:00") : undefined,
};
}, [rangeVal.from, rangeVal.to]);
const activePreset = RANGE_PRESETS.find((p) => {
const preset = computeRangePreset(p.key);
return preset.from === rangeVal.from && preset.to === rangeVal.to;
})?.key ?? null;
const handlePreset = useCallback(
(key: string) => {
const preset = computeRangePreset(key);
onChange(preset);
},
[onChange]
);
const handleRangeSelect = useCallback(
(range: { from?: Date; to?: Date } | undefined) => {
if (!range?.from) return;
const from = format(range.from, "yyyy-MM-dd");
const to = range.to ? format(range.to, "yyyy-MM-dd") : from;
onChange({ from, to });
if (range.to) setOpen(false);
},
[onChange]
);
const handleClear = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
onChange({});
},
[onChange]
);
const displayText = rangeVal.from
? rangeVal.from === rangeVal.to
? format(new Date(rangeVal.from + "T00:00:00"), "MM/dd (EEE)", { locale: ko })
: `${format(new Date(rangeVal.from + "T00:00:00"), "MM/dd", { locale: ko })} ~ ${rangeVal.to ? format(new Date(rangeVal.to + "T00:00:00"), "MM/dd", { locale: ko }) : ""}`
: "";
const presetBar = (
<div className="flex gap-1 px-3">
{RANGE_PRESETS.map((p) => (
<Button
key={p.key}
variant={activePreset === p.key ? "default" : "outline"}
size="sm"
className="h-7 flex-1 px-1 text-[10px]"
onClick={() => {
handlePreset(p.key);
setOpen(false);
}}
>
{p.label}
</Button>
))}
</div>
);
const calendarEl = (
<Calendar
mode="range"
selected={calendarRange}
onSelect={handleRangeSelect}
locale={ko}
defaultMonth={calendarRange?.from || new Date()}
numberOfMonths={1}
className={useModal ? "touch-date-calendar" : undefined}
/>
);
const triggerButton = (
<Button
variant="outline"
className={cn(
"h-full min-h-8 w-full justify-start gap-1.5 px-2 text-xs font-normal",
!rangeVal.from && "text-muted-foreground"
)}
onClick={useModal ? () => setOpen(true) : undefined}
>
<CalendarDays className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1 truncate text-left">
{displayText || (config.placeholder || "기간 선택")}
</span>
{rangeVal.from && (
<span
role="button"
tabIndex={-1}
onClick={handleClear}
onKeyDown={(e) => { if (e.key === "Enter") handleClear(e as unknown as React.MouseEvent); }}
className="shrink-0 text-muted-foreground hover:text-foreground"
>
<X className="h-3 w-3" />
</span>
)}
</Button>
);
if (useModal) {
return (
<>
{triggerButton}
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[360px] p-0">
<DialogHeader className="px-4 pt-4 pb-0">
<DialogTitle className="text-sm"> </DialogTitle>
</DialogHeader>
<div className="space-y-2 pb-4">
{presetBar}
<div className="flex justify-center">
{calendarEl}
</div>
</div>
</DialogContent>
</Dialog>
</>
);
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
{triggerButton}
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<div className="space-y-2 pt-2 pb-1">
{presetBar}
{calendarEl}
</div>
</PopoverContent>
</Popover>
);
}
// ========================================
// select 서브타입
// ========================================
@@ -237,7 +571,7 @@ function TextSearchInput({ config, value, onChange }: { config: PopSearchConfig;
function SelectSearchInput({ config, value, onChange }: { config: PopSearchConfig; value: string; onChange: (v: unknown) => void }) {
return (
<Select value={value || undefined} onValueChange={(v) => onChange(v)}>
<SelectTrigger className="h-8 text-xs">
<SelectTrigger className="h-full min-h-8 text-xs">
<SelectValue placeholder={config.placeholder || "선택"} />
</SelectTrigger>
<SelectContent>
@@ -266,7 +600,7 @@ function DatePresetSearchInput({ config, value, onChange }: { config: PopSearchC
};
return (
<div className="flex flex-wrap gap-1">
<div className="flex h-full flex-wrap items-center gap-1">
{presets.map((preset) => (
<Button key={preset} variant={currentPreset === preset ? "default" : "outline"} size="sm" className="h-7 px-2 text-[10px]" onClick={() => handleSelect(preset)}>
{DATE_PRESET_LABELS[preset]}
@@ -282,7 +616,7 @@ function DatePresetSearchInput({ config, value, onChange }: { config: PopSearchC
function ToggleSearchInput({ value, onChange }: { value: boolean; onChange: (v: unknown) => void }) {
return (
<div className="flex items-center gap-2">
<div className="flex h-full items-center gap-2">
<Switch checked={value} onCheckedChange={(checked) => onChange(checked)} />
<span className="text-xs text-muted-foreground">{value ? "ON" : "OFF"}</span>
</div>
@@ -293,17 +627,32 @@ function ToggleSearchInput({ value, onChange }: { value: boolean; onChange: (v:
// modal 서브타입: 읽기 전용 표시 + 클릭으로 모달 열기
// ========================================
function ModalSearchInput({ config, displayText, onClick }: { config: PopSearchConfig; displayText: string; onClick?: () => void }) {
function ModalSearchInput({ config, displayText, onClick, onClear }: { config: PopSearchConfig; displayText: string; onClick?: () => void; onClear?: () => void }) {
const hasValue = !!displayText;
return (
<div
className="flex h-8 cursor-pointer items-center rounded-md border border-input bg-background px-3 transition-colors hover:bg-accent"
className="flex h-full min-h-8 cursor-pointer items-center rounded-md border border-input bg-background px-3 transition-colors hover:bg-accent"
role="button"
tabIndex={0}
onClick={onClick}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") onClick?.(); }}
>
<span className="flex-1 truncate text-xs">{displayText || config.placeholder || "선택..."}</span>
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className={`flex-1 truncate text-xs ${hasValue ? "" : "text-muted-foreground"}`}>
{displayText || config.placeholder || "선택..."}
</span>
{hasValue && onClear ? (
<button
type="button"
className="ml-1 shrink-0 rounded-full p-0.5 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
onClick={(e) => { e.stopPropagation(); onClear(); }}
aria-label="선택 해제"
>
<X className="h-3.5 w-3.5" />
</button>
) : (
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
)}
</div>
);
}
@@ -314,7 +663,7 @@ function ModalSearchInput({ config, displayText, onClick }: { config: PopSearchC
function PlaceholderInput({ inputType }: { inputType: string }) {
return (
<div className="flex h-8 items-center rounded-md border border-dashed border-muted-foreground/30 px-3">
<div className="flex h-full min-h-8 items-center rounded-md border border-dashed border-muted-foreground/30 px-3">
<span className="text-[10px] text-muted-foreground">{inputType} ( )</span>
</div>
);
@@ -382,6 +731,7 @@ function ModalDialog({ open, onOpenChange, modalConfig, title, onSelect }: Modal
columnLabels,
displayStyle = "table",
displayField,
distinct,
} = modalConfig;
const colsToShow = displayColumns && displayColumns.length > 0 ? displayColumns : [];
@@ -393,13 +743,25 @@ function ModalDialog({ open, onOpenChange, modalConfig, title, onSelect }: Modal
setLoading(true);
try {
const result = await dataApi.getTableData(tableName, { page: 1, size: 200 });
setAllRows(result.data || []);
let rows = result.data || [];
if (distinct && displayField) {
const seen = new Set<string>();
rows = rows.filter((row) => {
const val = String(row[displayField] ?? "");
if (seen.has(val)) return false;
seen.add(val);
return true;
});
}
setAllRows(rows);
} catch {
setAllRows([]);
} finally {
setLoading(false);
}
}, [tableName]);
}, [tableName, distinct, displayField]);
useEffect(() => {
if (open) {
@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect } from "react";
import { useState, useEffect, useMemo } from "react";
import { cn } from "@/lib/utils";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -13,7 +13,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ChevronLeft, ChevronRight, Plus, Trash2, Loader2, Check, ChevronsUpDown } from "lucide-react";
import { ChevronLeft, ChevronRight, Plus, Trash2, Loader2, Check, ChevronsUpDown, AlertTriangle } from "lucide-react";
import {
Popover,
PopoverContent,
@@ -30,6 +30,9 @@ import {
import type {
PopSearchConfig,
SearchInputType,
SearchFilterMode,
DateSelectionMode,
CalendarDisplayMode,
DatePresetOption,
ModalSelectConfig,
ModalDisplayStyle,
@@ -38,6 +41,7 @@ import type {
} from "./types";
import {
SEARCH_INPUT_TYPE_LABELS,
SEARCH_FILTER_MODE_LABELS,
DATE_PRESET_LABELS,
MODAL_DISPLAY_STYLE_LABELS,
MODAL_SEARCH_MODE_LABELS,
@@ -57,7 +61,6 @@ const DEFAULT_CONFIG: PopSearchConfig = {
placeholder: "검색어 입력",
debounceMs: 500,
triggerOnEnter: true,
labelPosition: "top",
labelText: "",
labelVisible: true,
};
@@ -69,9 +72,12 @@ const DEFAULT_CONFIG: PopSearchConfig = {
interface ConfigPanelProps {
config: PopSearchConfig | undefined;
onUpdate: (config: PopSearchConfig) => void;
allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinitionV5[];
connections?: import("@/components/pop/designer/types/pop-layout").PopDataConnection[];
componentId?: string;
}
export function PopSearchConfigPanel({ config, onUpdate }: ConfigPanelProps) {
export function PopSearchConfigPanel({ config, onUpdate, allComponents, connections, componentId }: ConfigPanelProps) {
const [step, setStep] = useState(0);
const rawCfg = { ...DEFAULT_CONFIG, ...(config || {}) };
const cfg: PopSearchConfig = {
@@ -110,7 +116,7 @@ export function PopSearchConfigPanel({ config, onUpdate }: ConfigPanelProps) {
</div>
{step === 0 && <StepBasicSettings cfg={cfg} update={update} />}
{step === 1 && <StepDetailSettings cfg={cfg} update={update} />}
{step === 1 && <StepDetailSettings cfg={cfg} update={update} allComponents={allComponents} connections={connections} componentId={componentId} />}
<div className="flex justify-between pt-2">
<Button
@@ -145,6 +151,9 @@ export function PopSearchConfigPanel({ config, onUpdate }: ConfigPanelProps) {
interface StepProps {
cfg: PopSearchConfig;
update: (partial: Partial<PopSearchConfig>) => void;
allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinitionV5[];
connections?: import("@/components/pop/designer/types/pop-layout").PopDataConnection[];
componentId?: string;
}
function StepBasicSettings({ cfg, update }: StepProps) {
@@ -189,33 +198,17 @@ function StepBasicSettings({ cfg, update }: StepProps) {
</div>
{cfg.labelVisible !== false && (
<>
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Input
value={cfg.labelText || ""}
onChange={(e) => update({ labelText: e.target.value })}
placeholder="예: 거래처명"
className="h-8 text-xs"
/>
</div>
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Select
value={cfg.labelPosition || "top"}
onValueChange={(v) => update({ labelPosition: v as "top" | "left" })}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="top" className="text-xs"> ()</SelectItem>
<SelectItem value="left" className="text-xs"></SelectItem>
</SelectContent>
</Select>
</div>
</>
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Input
value={cfg.labelText || ""}
onChange={(e) => update({ labelText: e.target.value })}
placeholder="예: 거래처명"
className="h-8 text-xs"
/>
</div>
)}
</div>
);
}
@@ -224,18 +217,29 @@ function StepBasicSettings({ cfg, update }: StepProps) {
// STEP 2: 타입별 상세 설정
// ========================================
function StepDetailSettings({ cfg, update }: StepProps) {
function StepDetailSettings({ cfg, update, allComponents, connections, componentId }: StepProps) {
const normalized = normalizeInputType(cfg.inputType as string);
switch (normalized) {
case "text":
case "number":
return <TextDetailSettings cfg={cfg} update={update} />;
return <TextDetailSettings cfg={cfg} update={update} allComponents={allComponents} connections={connections} componentId={componentId} />;
case "select":
return <SelectDetailSettings cfg={cfg} update={update} />;
return <SelectDetailSettings cfg={cfg} update={update} allComponents={allComponents} connections={connections} componentId={componentId} />;
case "date":
return <DateDetailSettings cfg={cfg} update={update} allComponents={allComponents} connections={connections} componentId={componentId} />;
case "date-preset":
return <DatePresetDetailSettings cfg={cfg} update={update} />;
return <DatePresetDetailSettings cfg={cfg} update={update} allComponents={allComponents} connections={connections} componentId={componentId} />;
case "modal":
return <ModalDetailSettings cfg={cfg} update={update} />;
case "status-chip":
return (
<div className="rounded-lg bg-muted/50 p-3">
<p className="text-[10px] text-muted-foreground">
pop-status-bar .
&quot; &quot; .
</p>
</div>
);
case "toggle":
return (
<div className="rounded-lg bg-muted/50 p-3">
@@ -255,11 +259,278 @@ function StepDetailSettings({ cfg, update }: StepProps) {
}
}
// ========================================
// 공통: 필터 연결 설정 섹션
// ========================================
interface FilterConnectionSectionProps {
cfg: PopSearchConfig;
update: (partial: Partial<PopSearchConfig>) => void;
showFieldName: boolean;
fixedFilterMode?: SearchFilterMode;
allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinitionV5[];
connections?: import("@/components/pop/designer/types/pop-layout").PopDataConnection[];
componentId?: string;
}
interface ConnectedComponentInfo {
tableNames: string[];
displayedColumns: Set<string>;
}
/**
* tableName과 .
*/
function getConnectedComponentInfo(
componentId?: string,
connections?: import("@/components/pop/designer/types/pop-layout").PopDataConnection[],
allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinitionV5[],
): ConnectedComponentInfo {
const empty: ConnectedComponentInfo = { tableNames: [], displayedColumns: new Set() };
if (!componentId || !connections || !allComponents) return empty;
const targetIds = connections
.filter((c) => c.sourceComponent === componentId)
.map((c) => c.targetComponent);
const tableNames = new Set<string>();
const displayedColumns = new Set<string>();
for (const tid of targetIds) {
const comp = allComponents.find((c) => c.id === tid);
if (!comp?.config) continue;
const compCfg = comp.config as Record<string, any>;
const tn = compCfg.dataSource?.tableName;
if (tn) tableNames.add(tn);
// pop-card-list: cardTemplate에서 사용 중인 컬럼 수집
const tpl = compCfg.cardTemplate;
if (tpl) {
if (tpl.header?.codeField) displayedColumns.add(tpl.header.codeField);
if (tpl.header?.titleField) displayedColumns.add(tpl.header.titleField);
if (tpl.image?.imageColumn) displayedColumns.add(tpl.image.imageColumn);
if (Array.isArray(tpl.body?.fields)) {
for (const f of tpl.body.fields) {
if (f.columnName) displayedColumns.add(f.columnName);
}
}
}
// pop-string-list: selectedColumns / listColumns
if (Array.isArray(compCfg.selectedColumns)) {
for (const col of compCfg.selectedColumns) displayedColumns.add(col);
}
if (Array.isArray(compCfg.listColumns)) {
for (const lc of compCfg.listColumns) {
if (lc.columnName) displayedColumns.add(lc.columnName);
}
}
}
return { tableNames: Array.from(tableNames), displayedColumns };
}
function FilterConnectionSection({ cfg, update, showFieldName, fixedFilterMode, allComponents, connections, componentId }: FilterConnectionSectionProps) {
const connInfo = useMemo(
() => getConnectedComponentInfo(componentId, connections, allComponents),
[componentId, connections, allComponents],
);
const [targetColumns, setTargetColumns] = useState<ColumnTypeInfo[]>([]);
const [columnsLoading, setColumnsLoading] = useState(false);
const connectedTablesKey = connInfo.tableNames.join(",");
useEffect(() => {
if (connInfo.tableNames.length === 0) {
setTargetColumns([]);
return;
}
let cancelled = false;
setColumnsLoading(true);
Promise.all(connInfo.tableNames.map((t) => getTableColumns(t)))
.then((results) => {
if (cancelled) return;
const allCols: ColumnTypeInfo[] = [];
const seen = new Set<string>();
for (const res of results) {
if (res.success && res.data?.columns) {
for (const col of res.data.columns) {
if (!seen.has(col.columnName)) {
seen.add(col.columnName);
allCols.push(col);
}
}
}
}
setTargetColumns(allCols);
})
.finally(() => { if (!cancelled) setColumnsLoading(false); });
return () => { cancelled = true; };
}, [connectedTablesKey]); // eslint-disable-line react-hooks/exhaustive-deps
const hasConnection = connInfo.tableNames.length > 0;
const { displayedCols, otherCols } = useMemo(() => {
if (connInfo.displayedColumns.size === 0) {
return { displayedCols: [] as ColumnTypeInfo[], otherCols: targetColumns };
}
const displayed: ColumnTypeInfo[] = [];
const others: ColumnTypeInfo[] = [];
for (const col of targetColumns) {
if (connInfo.displayedColumns.has(col.columnName)) {
displayed.push(col);
} else {
others.push(col);
}
}
return { displayedCols: displayed, otherCols: others };
}, [targetColumns, connInfo.displayedColumns]);
const selectedFilterCols = cfg.filterColumns || (cfg.fieldName ? [cfg.fieldName] : []);
const toggleFilterColumn = (colName: string) => {
const current = new Set(selectedFilterCols);
if (current.has(colName)) {
current.delete(colName);
} else {
current.add(colName);
}
const next = Array.from(current);
update({
filterColumns: next,
fieldName: next[0] || "",
});
};
const renderColumnCheckbox = (col: ColumnTypeInfo) => (
<div key={col.columnName} className="flex items-center gap-2">
<Checkbox
id={`filter_col_${col.columnName}`}
checked={selectedFilterCols.includes(col.columnName)}
onCheckedChange={() => toggleFilterColumn(col.columnName)}
/>
<Label htmlFor={`filter_col_${col.columnName}`} className="text-[10px]">
{col.displayName || col.columnName}
<span className="ml-1 text-muted-foreground">({col.columnName})</span>
</Label>
</div>
);
return (
<div className="space-y-3">
<div className="flex items-center gap-2 border-t pt-3">
<span className="text-[10px] font-medium text-muted-foreground"> </span>
</div>
{!hasConnection && (
<div className="flex items-start gap-1.5 rounded border border-amber-200 bg-amber-50 p-2">
<AlertTriangle className="mt-0.5 h-3 w-3 shrink-0 text-amber-500" />
<p className="text-[9px] text-amber-700">
.
.
</p>
</div>
)}
{hasConnection && showFieldName && (
<div className="space-y-1">
<Label className="text-[10px]">
<span className="text-destructive">*</span>
</Label>
{columnsLoading ? (
<div className="flex h-8 items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
...
</div>
) : targetColumns.length > 0 ? (
<div className="max-h-48 space-y-2 overflow-y-auto rounded border p-2">
{displayedCols.length > 0 && (
<div className="space-y-1">
<p className="text-[9px] font-medium text-primary"> </p>
{displayedCols.map(renderColumnCheckbox)}
</div>
)}
{displayedCols.length > 0 && otherCols.length > 0 && (
<div className="border-t" />
)}
{otherCols.length > 0 && (
<div className="space-y-1">
<p className="text-[9px] font-medium text-muted-foreground"> </p>
{otherCols.map(renderColumnCheckbox)}
</div>
)}
</div>
) : (
<p className="text-[9px] text-muted-foreground">
</p>
)}
{selectedFilterCols.length === 0 && hasConnection && !columnsLoading && targetColumns.length > 0 && (
<div className="flex items-start gap-1.5 rounded border border-amber-200 bg-amber-50 p-2">
<AlertTriangle className="mt-0.5 h-3 w-3 shrink-0 text-amber-500" />
<p className="text-[9px] text-amber-700">
</p>
</div>
)}
{selectedFilterCols.length > 0 && (
<p className="text-[9px] text-muted-foreground">
{selectedFilterCols.length} -
</p>
)}
{selectedFilterCols.length === 0 && (
<p className="text-[9px] text-muted-foreground">
( )
</p>
)}
</div>
)}
{fixedFilterMode ? (
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<div className="flex h-8 items-center rounded-md border bg-muted px-3 text-xs text-muted-foreground">
{SEARCH_FILTER_MODE_LABELS[fixedFilterMode]}
</div>
<p className="text-[9px] text-muted-foreground">
{SEARCH_FILTER_MODE_LABELS[fixedFilterMode]}
</p>
</div>
) : (
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Select
value={cfg.filterMode || "contains"}
onValueChange={(v) => update({ filterMode: v as SearchFilterMode })}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(SEARCH_FILTER_MODE_LABELS).map(([key, label]) => (
<SelectItem key={key} value={key} className="text-xs">
{label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[9px] text-muted-foreground">
</p>
</div>
)}
</div>
);
}
// ========================================
// text/number 상세 설정
// ========================================
function TextDetailSettings({ cfg, update }: StepProps) {
function TextDetailSettings({ cfg, update, allComponents, connections, componentId }: StepProps) {
return (
<div className="space-y-3">
<div className="space-y-1">
@@ -285,6 +556,8 @@ function TextDetailSettings({ cfg, update }: StepProps) {
/>
<Label htmlFor="triggerOnEnter" className="text-[10px]">Enter </Label>
</div>
<FilterConnectionSection cfg={cfg} update={update} showFieldName allComponents={allComponents} connections={connections} componentId={componentId} />
</div>
);
}
@@ -293,7 +566,7 @@ function TextDetailSettings({ cfg, update }: StepProps) {
// select 상세 설정
// ========================================
function SelectDetailSettings({ cfg, update }: StepProps) {
function SelectDetailSettings({ cfg, update, allComponents, connections, componentId }: StepProps) {
const options = cfg.options || [];
const addOption = () => {
@@ -329,6 +602,90 @@ function SelectDetailSettings({ cfg, update }: StepProps) {
<Plus className="mr-1 h-3 w-3" />
</Button>
<FilterConnectionSection cfg={cfg} update={update} showFieldName allComponents={allComponents} connections={connections} componentId={componentId} />
</div>
);
}
// ========================================
// date 상세 설정
// ========================================
const DATE_SELECTION_MODE_LABELS: Record<DateSelectionMode, string> = {
single: "단일 날짜",
range: "기간 선택",
};
const CALENDAR_DISPLAY_LABELS: Record<CalendarDisplayMode, string> = {
popover: "팝오버 (PC용)",
modal: "모달 (터치/POP용)",
};
function DateDetailSettings({ cfg, update, allComponents, connections, componentId }: StepProps) {
const mode: DateSelectionMode = cfg.dateSelectionMode || "single";
const calDisplay: CalendarDisplayMode = cfg.calendarDisplay || "modal";
const autoFilterMode = mode === "range" ? "range" : "equals";
return (
<div className="space-y-3">
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Select
value={mode}
onValueChange={(v) => update({ dateSelectionMode: v as DateSelectionMode })}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(DATE_SELECTION_MODE_LABELS).map(([key, label]) => (
<SelectItem key={key} value={key} className="text-xs">
{label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[9px] text-muted-foreground">
{mode === "single"
? "캘린더에서 날짜 하나를 선택합니다"
: "프리셋(오늘/이번주/이번달) + 캘린더 기간 선택"}
</p>
</div>
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Select
value={calDisplay}
onValueChange={(v) => update({ calendarDisplay: v as CalendarDisplayMode })}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(CALENDAR_DISPLAY_LABELS).map(([key, label]) => (
<SelectItem key={key} value={key} className="text-xs">
{label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[9px] text-muted-foreground">
{calDisplay === "modal"
? "터치 친화적인 큰 모달로 캘린더가 열립니다"
: "입력란 아래에 작은 팝오버로 열립니다"}
</p>
</div>
<FilterConnectionSection
cfg={cfg}
update={update}
showFieldName
fixedFilterMode={autoFilterMode}
allComponents={allComponents}
connections={connections}
componentId={componentId}
/>
</div>
);
}
@@ -337,7 +694,7 @@ function SelectDetailSettings({ cfg, update }: StepProps) {
// date-preset 상세 설정
// ========================================
function DatePresetDetailSettings({ cfg, update }: StepProps) {
function DatePresetDetailSettings({ cfg, update, allComponents, connections, componentId }: StepProps) {
const ALL_PRESETS: DatePresetOption[] = ["today", "this-week", "this-month", "custom"];
const activePresets = cfg.datePresets || ["today", "this-week", "this-month"];
@@ -366,6 +723,8 @@ function DatePresetDetailSettings({ cfg, update }: StepProps) {
&quot;&quot; UI가 ( )
</p>
)}
<FilterConnectionSection cfg={cfg} update={update} showFieldName fixedFilterMode="range" allComponents={allComponents} connections={connections} componentId={componentId} />
</div>
);
}
@@ -647,6 +1006,21 @@ function ModalDetailSettings({ cfg, update }: StepProps) {
</p>
</div>
{/* 중복 제거 (Distinct) */}
<div className="space-y-1">
<div className="flex items-center gap-1.5">
<Checkbox
id="modal_distinct"
checked={mc.distinct ?? false}
onCheckedChange={(checked) => updateModal({ distinct: !!checked })}
/>
<Label htmlFor="modal_distinct" className="text-[10px]"> (Distinct)</Label>
</div>
<p className="text-[9px] text-muted-foreground">
</p>
</div>
{/* 검색창에 보일 값 */}
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
@@ -694,8 +1068,11 @@ function ModalDetailSettings({ cfg, update }: StepProps) {
(: 회사코드)
</p>
</div>
<FilterConnectionSection cfg={cfg} update={update} showFieldName={false} />
</>
)}
</div>
);
}
@@ -1,7 +1,7 @@
// ===== pop-search 전용 타입 =====
// 단일 필드 검색 컴포넌트. 그리드 한 칸 = 검색 필드 하나.
/** 검색 필드 입력 타입 (9종) */
/** 검색 필드 입력 타입 (10종) */
export type SearchInputType =
| "text"
| "number"
@@ -11,7 +11,8 @@ export type SearchInputType =
| "multi-select"
| "combo"
| "modal"
| "toggle";
| "toggle"
| "status-chip";
/** 레거시 입력 타입 (DB에 저장된 기존 값 호환용) */
export type LegacySearchInputType = "modal-table" | "modal-card" | "modal-icon-grid";
@@ -22,6 +23,12 @@ export function normalizeInputType(t: string): SearchInputType {
return t as SearchInputType;
}
/** 날짜 선택 모드 */
export type DateSelectionMode = "single" | "range";
/** 캘린더 표시 방식 (POP 터치 환경에서는 modal 권장) */
export type CalendarDisplayMode = "popover" | "modal";
/** 날짜 프리셋 옵션 */
export type DatePresetOption = "today" | "this-week" | "this-month" | "custom";
@@ -46,6 +53,9 @@ export type ModalDisplayStyle = "table" | "icon";
/** 모달 검색 방식 */
export type ModalSearchMode = "contains" | "starts-with" | "equals";
/** 검색 값을 대상 리스트에 전달할 때의 필터링 방식 */
export type SearchFilterMode = "contains" | "equals" | "starts_with" | "range";
/** 모달 필터 탭 (가나다 초성 / ABC 알파벳) */
export type ModalFilterTab = "korean" | "alphabet";
@@ -64,6 +74,22 @@ export interface ModalSelectConfig {
displayField: string;
valueField: string;
/** displayField 기준 중복 제거 */
distinct?: boolean;
}
/** @deprecated status-chip은 pop-status-bar로 분리됨. 레거시 호환용. */
export type StatusChipStyle = "tab" | "pill";
/** @deprecated status-chip은 pop-status-bar로 분리됨. 레거시 호환용. */
export interface StatusChipConfig {
showCount?: boolean;
countColumn?: string;
allowAll?: boolean;
allLabel?: string;
chipStyle?: StatusChipStyle;
useSubCount?: boolean;
}
/** pop-search 전체 설정 */
@@ -81,18 +107,28 @@ export interface PopSearchConfig {
options?: SelectOption[];
optionsDataSource?: SelectDataSource;
// date 전용
dateSelectionMode?: DateSelectionMode;
calendarDisplay?: CalendarDisplayMode;
// date-preset 전용
datePresets?: DatePresetOption[];
// modal 전용
modalConfig?: ModalSelectConfig;
// status-chip 전용
statusChipConfig?: StatusChipConfig;
// 라벨
labelText?: string;
labelVisible?: boolean;
// 스타일
labelPosition?: "top" | "left";
// 연결된 리스트에 필터를 보낼 때의 매칭 방식
filterMode?: SearchFilterMode;
// 필터 대상 컬럼 복수 선택 (fieldName은 대표 컬럼, filterColumns는 전체 대상)
filterColumns?: string[];
}
/** 기본 설정값 (레지스트리 + 컴포넌트 공유) */
@@ -102,7 +138,6 @@ export const DEFAULT_SEARCH_CONFIG: PopSearchConfig = {
placeholder: "검색어 입력",
debounceMs: 500,
triggerOnEnter: true,
labelPosition: "top",
labelText: "",
labelVisible: true,
};
@@ -126,6 +161,13 @@ export const SEARCH_INPUT_TYPE_LABELS: Record<SearchInputType, string> = {
combo: "자동완성",
modal: "모달",
toggle: "토글",
"status-chip": "상태 칩 (대시보드)",
};
/** 상태 칩 스타일 라벨 (설정 패널용) */
export const STATUS_CHIP_STYLE_LABELS: Record<StatusChipStyle, string> = {
tab: "탭 (큰 숫자)",
pill: "알약 (작은 뱃지)",
};
/** 모달 보여주기 방식 라벨 */
@@ -147,6 +189,14 @@ export const MODAL_FILTER_TAB_LABELS: Record<ModalFilterTab, string> = {
alphabet: "ABC",
};
/** 검색 필터 방식 라벨 (설정 패널용) */
export const SEARCH_FILTER_MODE_LABELS: Record<SearchFilterMode, string> = {
contains: "포함",
equals: "일치",
starts_with: "시작",
range: "범위",
};
/** 한글 초성 추출 */
const KOREAN_CONSONANTS = [
"ㄱ", "ㄲ", "ㄴ", "ㄷ", "ㄸ", "ㄹ", "ㅁ", "ㅂ", "ㅃ",
@@ -38,9 +38,23 @@ export function ColumnCombobox({
const filtered = useMemo(() => {
if (!search) return columns;
const q = search.toLowerCase();
return columns.filter((c) => c.name.toLowerCase().includes(q));
return columns.filter(
(c) =>
c.name.toLowerCase().includes(q) ||
(c.comment && c.comment.toLowerCase().includes(q))
);
}, [columns, search]);
const selectedCol = useMemo(
() => columns.find((c) => c.name === value),
[columns, value],
);
const displayValue = selectedCol
? selectedCol.comment
? `${selectedCol.name} (${selectedCol.comment})`
: selectedCol.name
: "";
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
@@ -50,7 +64,7 @@ export function ColumnCombobox({
aria-expanded={open}
className="mt-1 h-8 w-full justify-between text-xs"
>
{value || placeholder}
<span className="truncate">{displayValue || placeholder}</span>
<ChevronsUpDown className="ml-2 h-3.5 w-3.5 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
@@ -61,7 +75,7 @@ export function ColumnCombobox({
>
<Command shouldFilter={false}>
<CommandInput
placeholder="컬럼명 검색..."
placeholder="컬럼명 또는 한글명 검색..."
className="text-xs"
value={search}
onValueChange={setSearch}
@@ -88,8 +102,15 @@ export function ColumnCombobox({
value === col.name ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex items-center gap-2">
<span>{col.name}</span>
<div className="flex flex-col">
<div className="flex items-center gap-2">
<span>{col.name}</span>
{col.comment && (
<span className="text-[11px] text-muted-foreground">
({col.comment})
</span>
)}
</div>
<span className="text-[10px] text-muted-foreground">
{col.type}
</span>
@@ -0,0 +1,243 @@
"use client";
import { useState, useCallback, useEffect, useMemo } from "react";
import { cn } from "@/lib/utils";
import { usePopEvent } from "@/hooks/pop";
import type { StatusBarConfig, StatusChipOption } from "./types";
import { DEFAULT_STATUS_BAR_CONFIG } from "./types";
interface PopStatusBarComponentProps {
config: StatusBarConfig;
label?: string;
screenId?: string;
componentId?: string;
}
export function PopStatusBarComponent({
config: rawConfig,
label,
screenId,
componentId,
}: PopStatusBarComponentProps) {
const config = { ...DEFAULT_STATUS_BAR_CONFIG, ...(rawConfig || {}) };
const { publish, subscribe } = usePopEvent(screenId || "");
const [selectedValue, setSelectedValue] = useState<string>("");
const [allRows, setAllRows] = useState<Record<string, unknown>[]>([]);
const [autoSubStatusColumn, setAutoSubStatusColumn] = useState<string | null>(null);
// all_rows 이벤트 구독
useEffect(() => {
if (!componentId) return;
const unsub = subscribe(
`__comp_input__${componentId}__all_rows`,
(payload: unknown) => {
const data = payload as { value?: unknown } | unknown;
const inner =
typeof data === "object" && data && "value" in data
? (data as { value: unknown }).value
: data;
if (
typeof inner === "object" &&
inner &&
!Array.isArray(inner) &&
"rows" in inner
) {
const envelope = inner as {
rows?: unknown;
subStatusColumn?: string | null;
};
if (Array.isArray(envelope.rows))
setAllRows(envelope.rows as Record<string, unknown>[]);
setAutoSubStatusColumn(envelope.subStatusColumn ?? null);
} else if (Array.isArray(inner)) {
setAllRows(inner as Record<string, unknown>[]);
setAutoSubStatusColumn(null);
}
}
);
return unsub;
}, [componentId, subscribe]);
// 외부에서 값 설정 이벤트 구독
useEffect(() => {
if (!componentId) return;
const unsub = subscribe(
`__comp_input__${componentId}__set_value`,
(payload: unknown) => {
const data = payload as { value?: unknown } | unknown;
const incoming =
typeof data === "object" && data && "value" in data
? (data as { value: unknown }).value
: data;
setSelectedValue(String(incoming ?? ""));
}
);
return unsub;
}, [componentId, subscribe]);
const emitFilter = useCallback(
(newValue: string) => {
setSelectedValue(newValue);
if (!componentId) return;
const baseColumn = config.filterColumn || config.countColumn || "";
const subActive = config.useSubCount && !!autoSubStatusColumn;
const filterColumns = subActive
? [...new Set([baseColumn, autoSubStatusColumn!].filter(Boolean))]
: [baseColumn].filter(Boolean);
publish(`__comp_output__${componentId}__filter_value`, {
fieldName: baseColumn,
filterColumns,
value: newValue,
filterMode: "equals",
_source: "status-bar",
});
},
[componentId, publish, config.filterColumn, config.countColumn, config.useSubCount, autoSubStatusColumn]
);
const chipCfg = config;
const showCount = chipCfg.showCount !== false;
const baseCountColumn = chipCfg.countColumn || "";
const useSubCount = chipCfg.useSubCount || false;
const hideUntilSubFilter = chipCfg.hideUntilSubFilter || false;
const allowAll = chipCfg.allowAll !== false;
const allLabel = chipCfg.allLabel || "전체";
const chipStyle = chipCfg.chipStyle || "tab";
const options: StatusChipOption[] = chipCfg.options || [];
// 하위 필터(공정) 활성 여부
const subFilterActive = useSubCount && !!autoSubStatusColumn;
// hideUntilSubFilter가 켜져있으면서 아직 공정 선택이 안 된 경우 숨김
const shouldHide = hideUntilSubFilter && !subFilterActive;
const effectiveCountColumn =
subFilterActive ? autoSubStatusColumn : baseCountColumn;
const counts = useMemo(() => {
if (!showCount || !effectiveCountColumn || allRows.length === 0)
return new Map<string, number>();
const map = new Map<string, number>();
for (const row of allRows) {
if (row == null || typeof row !== "object") continue;
const v = String(row[effectiveCountColumn] ?? "");
map.set(v, (map.get(v) || 0) + 1);
}
return map;
}, [allRows, effectiveCountColumn, showCount]);
const totalCount = allRows.length;
const chipItems = useMemo(() => {
const items: { value: string; label: string; count: number }[] = [];
if (allowAll) {
items.push({ value: "", label: allLabel, count: totalCount });
}
for (const opt of options) {
items.push({
value: opt.value,
label: opt.label,
count: counts.get(opt.value) || 0,
});
}
return items;
}, [options, counts, totalCount, allowAll, allLabel]);
const showLabel = !!label;
if (shouldHide) {
return (
<div className="flex h-full w-full items-center justify-center p-1.5">
<span className="text-[10px] text-muted-foreground/50">
{chipCfg.hiddenMessage || "조건을 선택하면 상태별 현황이 표시됩니다"}
</span>
</div>
);
}
if (chipStyle === "pill") {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-0.5 overflow-hidden p-1.5">
{showLabel && (
<span className="w-full shrink-0 truncate text-[10px] font-medium text-muted-foreground">
{label}
</span>
)}
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-1.5">
{chipItems.map((item) => {
const isActive = selectedValue === item.value;
return (
<button
key={item.value}
type="button"
onClick={() => emitFilter(item.value)}
className={cn(
"flex items-center gap-1 rounded-full px-3 py-1 text-xs font-medium transition-colors",
isActive
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:bg-accent hover:text-foreground"
)}
>
{item.label}
{showCount && (
<span
className={cn(
"ml-0.5 min-w-[18px] rounded-full px-1 py-0.5 text-center text-[10px] font-bold leading-none",
isActive
? "bg-primary-foreground/20 text-primary-foreground"
: "bg-background text-foreground"
)}
>
{item.count}
</span>
)}
</button>
);
})}
</div>
</div>
);
}
// tab 스타일 (기본)
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-0.5 overflow-hidden p-1.5">
{showLabel && (
<span className="w-full shrink-0 truncate text-[10px] font-medium text-muted-foreground">
{label}
</span>
)}
<div className="flex min-w-0 flex-1 items-center justify-center gap-2">
{chipItems.map((item) => {
const isActive = selectedValue === item.value;
return (
<button
key={item.value}
type="button"
onClick={() => emitFilter(item.value)}
className={cn(
"flex min-w-[60px] flex-col items-center justify-center rounded-lg px-3 py-1.5 transition-colors",
isActive
? "bg-primary text-primary-foreground shadow-sm"
: "bg-muted/60 text-muted-foreground hover:bg-accent"
)}
>
{showCount && (
<span className="text-lg font-bold leading-tight">
{item.count}
</span>
)}
<span className="text-[10px] font-medium leading-tight">
{item.label}
</span>
</button>
);
})}
</div>
</div>
);
}
@@ -0,0 +1,489 @@
"use client";
import { useState, useEffect, useMemo, useCallback } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Plus, Trash2, Loader2, AlertTriangle, RefreshCw } from "lucide-react";
import { getTableColumns } from "@/lib/api/tableManagement";
import { dataApi } from "@/lib/api/data";
import type { ColumnTypeInfo } from "@/lib/api/tableManagement";
import type { StatusBarConfig, StatusChipStyle, StatusChipOption } from "./types";
import { DEFAULT_STATUS_BAR_CONFIG, STATUS_CHIP_STYLE_LABELS } from "./types";
interface ConfigPanelProps {
config: StatusBarConfig | undefined;
onUpdate: (config: StatusBarConfig) => void;
allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinitionV5[];
connections?: import("@/components/pop/designer/types/pop-layout").PopDataConnection[];
componentId?: string;
}
export function PopStatusBarConfigPanel({
config: rawConfig,
onUpdate,
allComponents,
connections,
componentId,
}: ConfigPanelProps) {
const cfg = { ...DEFAULT_STATUS_BAR_CONFIG, ...(rawConfig || {}) };
const update = (partial: Partial<StatusBarConfig>) => {
onUpdate({ ...cfg, ...partial });
};
const options = cfg.options || [];
const removeOption = (index: number) => {
update({ options: options.filter((_, i) => i !== index) });
};
const updateOption = (
index: number,
field: keyof StatusChipOption,
val: string
) => {
update({
options: options.map((opt, i) =>
i === index ? { ...opt, [field]: val } : opt
),
});
};
// 연결된 카드 컴포넌트의 테이블 컬럼 가져오기
const connectedTableName = useMemo(() => {
if (!componentId || !connections || !allComponents) return null;
const targetIds = connections
.filter((c) => c.sourceComponent === componentId)
.map((c) => c.targetComponent);
const sourceIds = connections
.filter((c) => c.targetComponent === componentId)
.map((c) => c.sourceComponent);
const peerIds = [...new Set([...targetIds, ...sourceIds])];
for (const pid of peerIds) {
const comp = allComponents.find((c) => c.id === pid);
if (!comp?.config) continue;
const compCfg = comp.config as Record<string, unknown>;
const ds = compCfg.dataSource as { tableName?: string } | undefined;
if (ds?.tableName) return ds.tableName;
}
return null;
}, [componentId, connections, allComponents]);
const [targetColumns, setTargetColumns] = useState<ColumnTypeInfo[]>([]);
const [columnsLoading, setColumnsLoading] = useState(false);
// 집계 컬럼의 고유값 (옵션 선택용)
const [distinctValues, setDistinctValues] = useState<string[]>([]);
const [distinctLoading, setDistinctLoading] = useState(false);
useEffect(() => {
if (!connectedTableName) {
setTargetColumns([]);
return;
}
let cancelled = false;
setColumnsLoading(true);
getTableColumns(connectedTableName)
.then((res) => {
if (cancelled) return;
if (res.success && res.data?.columns) {
setTargetColumns(res.data.columns);
}
})
.finally(() => {
if (!cancelled) setColumnsLoading(false);
});
return () => {
cancelled = true;
};
}, [connectedTableName]);
const fetchDistinctValues = useCallback(async (tableName: string, column: string) => {
setDistinctLoading(true);
try {
const res = await dataApi.getTableData(tableName, { page: 1, size: 9999 });
const vals = new Set<string>();
for (const row of res.data) {
const v = row[column];
if (v != null && String(v).trim() !== "") {
vals.add(String(v));
}
}
const sorted = [...vals].sort();
setDistinctValues(sorted);
return sorted;
} catch {
setDistinctValues([]);
return [];
} finally {
setDistinctLoading(false);
}
}, []);
// 집계 컬럼 변경 시 고유값 새로 가져오기
useEffect(() => {
const col = cfg.countColumn;
if (!connectedTableName || !col) {
setDistinctValues([]);
return;
}
fetchDistinctValues(connectedTableName, col);
}, [connectedTableName, cfg.countColumn, fetchDistinctValues]);
const handleAutoFill = useCallback(async () => {
if (!connectedTableName || !cfg.countColumn) return;
const vals = await fetchDistinctValues(connectedTableName, cfg.countColumn);
if (vals.length === 0) return;
const newOptions: StatusChipOption[] = vals.map((v) => {
const existing = options.find((o) => o.value === v);
return { value: v, label: existing?.label || v };
});
update({ options: newOptions });
}, [connectedTableName, cfg.countColumn, options, fetchDistinctValues]);
const addOptionFromValue = (value: string) => {
if (options.some((o) => o.value === value)) return;
update({
options: [...options, { value, label: value }],
});
};
return (
<div className="space-y-4">
{/* --- 칩 옵션 목록 --- */}
<div className="space-y-1">
<div className="flex items-center justify-between">
<Label className="text-[10px]"> </Label>
{connectedTableName && cfg.countColumn && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-[9px]"
onClick={handleAutoFill}
disabled={distinctLoading}
>
{distinctLoading ? (
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
) : (
<RefreshCw className="mr-1 h-3 w-3" />
)}
DB에서
</Button>
)}
</div>
{cfg.useSubCount && (
<div className="flex items-start gap-1.5 rounded border border-amber-200 bg-amber-50 p-2">
<AlertTriangle className="mt-0.5 h-3 w-3 shrink-0 text-amber-500" />
<p className="text-[9px] text-amber-700">
. DB .
</p>
</div>
)}
{options.length === 0 && (
<p className="text-[9px] text-muted-foreground">
{connectedTableName && cfg.countColumn
? "\"DB에서 자동 채우기\"를 클릭하거나 아래에서 추가하세요."
: "옵션이 없습니다. 먼저 집계 컬럼을 선택한 후 추가하세요."}
</p>
)}
{options.map((opt, i) => (
<div key={i} className="flex items-center gap-1">
<Input
value={opt.value}
onChange={(e) => updateOption(i, "value", e.target.value)}
placeholder="DB 값"
className="h-7 flex-1 text-[10px]"
/>
<Input
value={opt.label}
onChange={(e) => updateOption(i, "label", e.target.value)}
placeholder="표시 라벨"
className="h-7 flex-1 text-[10px]"
/>
<button
type="button"
onClick={() => removeOption(i)}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
>
<Trash2 className="h-3 w-3" />
</button>
</div>
))}
{/* 고유값에서 추가 */}
{distinctValues.length > 0 && (
<div className="space-y-1">
<Label className="text-[9px] text-muted-foreground">
(DB에서 )
</Label>
<div className="flex flex-wrap gap-1">
{distinctValues
.filter((dv) => !options.some((o) => o.value === dv))
.map((dv) => (
<button
key={dv}
type="button"
onClick={() => addOptionFromValue(dv)}
className="flex h-6 items-center gap-1 rounded-full border border-dashed px-2 text-[9px] text-muted-foreground transition-colors hover:border-primary hover:text-primary"
>
<Plus className="h-2.5 w-2.5" />
{dv}
</button>
))}
{distinctValues.every((dv) => options.some((o) => o.value === dv)) && (
<p className="text-[9px] text-muted-foreground"> </p>
)}
</div>
</div>
)}
{/* 수동 추가 */}
<Button
variant="outline"
size="sm"
className="h-7 w-full text-[10px]"
onClick={() => {
update({
options: [
...options,
{ value: "", label: "" },
],
});
}}
>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
{/* --- 전체 보기 칩 --- */}
<div className="space-y-1">
<div className="flex items-center gap-2">
<Checkbox
id="allowAll"
checked={cfg.allowAll !== false}
onCheckedChange={(checked) => update({ allowAll: Boolean(checked) })}
/>
<Label htmlFor="allowAll" className="text-[10px]">
&quot;&quot;
</Label>
</div>
<p className="pl-5 text-[9px] text-muted-foreground">
</p>
{cfg.allowAll !== false && (
<div className="space-y-1 pl-5">
<Label className="text-[9px] text-muted-foreground">
</Label>
<Input
value={cfg.allLabel || ""}
onChange={(e) => update({ allLabel: e.target.value })}
placeholder="전체"
className="h-7 text-[10px]"
/>
</div>
)}
</div>
{/* --- 건수 표시 --- */}
<div className="flex items-center gap-2">
<Checkbox
id="showCount"
checked={cfg.showCount !== false}
onCheckedChange={(checked) => update({ showCount: Boolean(checked) })}
/>
<Label htmlFor="showCount" className="text-[10px]">
</Label>
</div>
{cfg.showCount !== false && (
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
{columnsLoading ? (
<div className="flex h-8 items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
...
</div>
) : targetColumns.length > 0 ? (
<Select
value={cfg.countColumn || ""}
onValueChange={(v) => update({ countColumn: v })}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="집계 컬럼 선택" />
</SelectTrigger>
<SelectContent>
{targetColumns.map((col) => (
<SelectItem
key={col.columnName}
value={col.columnName}
className="text-xs"
>
{col.displayName || col.columnName}
<span className="ml-1 text-muted-foreground">
({col.columnName})
</span>
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={cfg.countColumn || ""}
onChange={(e) => update({ countColumn: e.target.value })}
placeholder="예: status"
className="h-8 text-xs"
/>
)}
<p className="text-[9px] text-muted-foreground">
</p>
</div>
)}
{cfg.showCount !== false && (
<div className="space-y-2 rounded bg-muted/30 p-2">
<div className="flex items-center gap-2">
<Checkbox
id="useSubCount"
checked={cfg.useSubCount || false}
onCheckedChange={(checked) =>
update({ useSubCount: Boolean(checked) })
}
/>
<Label htmlFor="useSubCount" className="text-[10px]">
</Label>
</div>
{cfg.useSubCount && (
<>
<p className="pl-5 text-[9px] text-muted-foreground">
</p>
<div className="mt-1 flex items-center gap-2 pl-5">
<Checkbox
id="hideUntilSubFilter"
checked={cfg.hideUntilSubFilter || false}
onCheckedChange={(checked) =>
update({ hideUntilSubFilter: Boolean(checked) })
}
/>
<Label htmlFor="hideUntilSubFilter" className="text-[10px]">
</Label>
</div>
{cfg.hideUntilSubFilter && (
<div className="space-y-1 pl-5">
<Label className="text-[9px] text-muted-foreground">
</Label>
<Input
value={cfg.hiddenMessage || ""}
onChange={(e) => update({ hiddenMessage: e.target.value })}
placeholder="조건을 선택하면 상태별 현황이 표시됩니다"
className="h-7 text-[10px]"
/>
</div>
)}
</>
)}
</div>
)}
{/* --- 칩 스타일 --- */}
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Select
value={cfg.chipStyle || "tab"}
onValueChange={(v) => update({ chipStyle: v as StatusChipStyle })}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(STATUS_CHIP_STYLE_LABELS).map(([key, label]) => (
<SelectItem key={key} value={key} className="text-xs">
{label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[9px] text-muted-foreground">
: + / 알약: 작은
</p>
</div>
{/* --- 필터 컬럼 --- */}
<div className="space-y-1 border-t pt-3">
<Label className="text-[10px]"> </Label>
{!connectedTableName && (
<div className="flex items-start gap-1.5 rounded border border-amber-200 bg-amber-50 p-2">
<AlertTriangle className="mt-0.5 h-3 w-3 shrink-0 text-amber-500" />
<p className="text-[9px] text-amber-700">
.
</p>
</div>
)}
{connectedTableName && (
<>
{columnsLoading ? (
<div className="flex h-8 items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
...
</div>
) : targetColumns.length > 0 ? (
<Select
value={cfg.filterColumn || cfg.countColumn || ""}
onValueChange={(v) => update({ filterColumn: v })}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="필터 컬럼 선택" />
</SelectTrigger>
<SelectContent>
{targetColumns.map((col) => (
<SelectItem
key={col.columnName}
value={col.columnName}
className="text-xs"
>
{col.displayName || col.columnName}
<span className="ml-1 text-muted-foreground">
({col.columnName})
</span>
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={cfg.filterColumn || ""}
onChange={(e) => update({ filterColumn: e.target.value })}
placeholder="예: status"
className="h-8 text-xs"
/>
)}
<p className="text-[9px] text-muted-foreground">
(
)
</p>
</>
)}
</div>
</div>
);
}
@@ -0,0 +1,87 @@
"use client";
import { PopComponentRegistry } from "../../PopComponentRegistry";
import { PopStatusBarComponent } from "./PopStatusBarComponent";
import { PopStatusBarConfigPanel } from "./PopStatusBarConfig";
import type { StatusBarConfig } from "./types";
import { DEFAULT_STATUS_BAR_CONFIG } from "./types";
function PopStatusBarPreviewComponent({
config,
label,
}: {
config?: StatusBarConfig;
label?: string;
}) {
const cfg = config || DEFAULT_STATUS_BAR_CONFIG;
const options = cfg.options || [];
const displayLabel = label || "상태 바";
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-1 p-2">
<span className="text-[10px] font-medium text-muted-foreground">
{displayLabel}
</span>
<div className="flex items-center gap-1">
{options.length === 0 ? (
<span className="text-[9px] text-muted-foreground">
</span>
) : (
options.slice(0, 4).map((opt) => (
<div
key={opt.value}
className="flex flex-col items-center rounded bg-muted/60 px-2 py-0.5"
>
<span className="text-[10px] font-bold leading-tight">0</span>
<span className="text-[8px] leading-tight text-muted-foreground">
{opt.label}
</span>
</div>
))
)}
</div>
</div>
);
}
PopComponentRegistry.registerComponent({
id: "pop-status-bar",
name: "상태 바",
description: "상태별 건수 대시보드 + 필터",
category: "display",
icon: "BarChart3",
component: PopStatusBarComponent,
configPanel: PopStatusBarConfigPanel,
preview: PopStatusBarPreviewComponent,
defaultProps: DEFAULT_STATUS_BAR_CONFIG,
connectionMeta: {
sendable: [
{
key: "filter_value",
label: "필터 값",
type: "filter_value",
category: "filter",
description: "선택한 상태 칩 값을 카드에 필터로 전달",
},
],
receivable: [
{
key: "all_rows",
label: "전체 데이터",
type: "all_rows",
category: "data",
description: "연결된 카드의 전체 데이터를 받아 상태별 건수 집계",
},
{
key: "set_value",
label: "값 설정",
type: "filter_value",
category: "filter",
description: "외부에서 선택 값 설정",
},
],
},
touchOptimized: true,
supportedDevices: ["mobile", "tablet"],
});
@@ -0,0 +1,48 @@
// ===== pop-status-bar 전용 타입 =====
// 상태 칩 대시보드 컴포넌트. 카드 데이터를 집계하여 상태별 건수 표시 + 필터 발행.
/** 상태 칩 표시 스타일 */
export type StatusChipStyle = "tab" | "pill";
/** 개별 옵션 */
export interface StatusChipOption {
value: string;
label: string;
}
/** status-bar 전용 설정 */
export interface StatusBarConfig {
showCount?: boolean;
countColumn?: string;
allowAll?: boolean;
allLabel?: string;
chipStyle?: StatusChipStyle;
/** 하위 필터 적용 시 집계 컬럼 자동 전환 (카드가 전달하는 가상 컬럼 사용) */
useSubCount?: boolean;
/** 하위 필터(공정 선택 등)가 활성화되기 전까지 칩을 숨김 */
hideUntilSubFilter?: boolean;
/** 칩 숨김 상태일 때 표시할 안내 문구 */
hiddenMessage?: string;
options?: StatusChipOption[];
/** 필터 대상 컬럼명 (기본: countColumn) */
filterColumn?: string;
/** 추가 필터 대상 컬럼 (하위 테이블 등) */
filterColumns?: string[];
}
/** 기본 설정값 */
export const DEFAULT_STATUS_BAR_CONFIG: StatusBarConfig = {
showCount: true,
allowAll: true,
allLabel: "전체",
chipStyle: "tab",
options: [],
};
/** 칩 스타일 라벨 (설정 패널용) */
export const STATUS_CHIP_STYLE_LABELS: Record<StatusChipStyle, string> = {
tab: "탭 (큰 숫자)",
pill: "알약 (작은 뱃지)",
};
@@ -193,10 +193,9 @@ export function PopStringListComponent({
row: RowData,
filter: { fieldName: string; value: unknown; filterConfig?: { targetColumn: string; targetColumns?: string[]; filterMode: string } }
): boolean => {
const searchValue = String(filter.value).toLowerCase();
if (!searchValue) return true;
const fc = filter.filterConfig;
const mode = fc?.filterMode || "contains";
const columns: string[] =
fc?.targetColumns?.length
? fc.targetColumns
@@ -208,17 +207,46 @@ export function PopStringListComponent({
if (columns.length === 0) return true;
const mode = fc?.filterMode || "contains";
// range 모드: { from, to } 객체 또는 단일 날짜 문자열 지원
if (mode === "range") {
const val = filter.value as { from?: string; to?: string } | string;
let from = "";
let to = "";
if (typeof val === "object" && val !== null) {
from = val.from || "";
to = val.to || "";
} else {
from = String(val || "");
to = from;
}
if (!from && !to) return true;
return columns.some((col) => {
const cellDate = String(row[col] ?? "").slice(0, 10);
if (!cellDate) return false;
if (from && cellDate < from) return false;
if (to && cellDate > to) return false;
return true;
});
}
// 문자열 기반 필터 (contains, equals, starts_with)
const searchValue = String(filter.value ?? "").toLowerCase();
if (!searchValue) return true;
// 날짜 패턴 감지 (YYYY-MM-DD): equals 비교 시 ISO 타임스탬프에서 날짜만 추출
const isDateValue = /^\d{4}-\d{2}-\d{2}$/.test(searchValue);
const matchCell = (cellValue: string) => {
const target = isDateValue && mode === "equals" ? cellValue.slice(0, 10) : cellValue;
switch (mode) {
case "equals":
return cellValue === searchValue;
return target === searchValue;
case "starts_with":
return cellValue.startsWith(searchValue);
return target.startsWith(searchValue);
case "contains":
default:
return cellValue.includes(searchValue);
return target.includes(searchValue);
}
};
@@ -722,3 +722,264 @@ export interface PopCardListConfig {
cartListMode?: CartListModeConfig;
saveMapping?: CardListSaveMapping;
}
// =============================================
// pop-card-list-v2 전용 타입 (슬롯 기반 카드)
// =============================================
import type { ButtonMainAction, ButtonVariant, ConfirmConfig } from "./pop-button";
export type CardCellType =
| "text"
| "field"
| "image"
| "badge"
| "button"
| "number-input"
| "cart-button"
| "package-summary"
| "status-badge"
| "timeline"
| "action-buttons"
| "footer-status";
// timeline 셀에서 사용하는 하위 단계 데이터
export interface TimelineProcessStep {
seqNo: number;
processName: string;
status: string; // DB 원본 값 (또는 derivedFrom에 의해 변환된 값)
semantic?: "pending" | "active" | "done"; // 시각적 의미 (렌더러 색상 결정)
isCurrent: boolean;
processId?: string | number; // 공정 테이블 레코드 PK (접수 등 UPDATE 대상 특정용)
rawData?: Record<string, unknown>; // 하위 테이블 원본 행 (하위 필터 매칭용)
}
// timeline/status-badge/action-buttons가 참조하는 하위 테이블 설정
export interface TimelineDataSource {
processTable: string; // 하위 데이터 테이블명 (예: work_order_process)
foreignKey: string; // 메인 테이블 id와 매칭되는 FK 컬럼 (예: wo_id)
seqColumn: string; // 순서 컬럼 (예: seq_no)
nameColumn: string; // 표시명 컬럼 (예: process_name)
statusColumn: string; // 상태 컬럼 (예: status)
// 상태 값 매핑: DB값 → 시맨틱 (동적 배열, 순서대로 표시)
// 레거시 호환: 기존 { waiting, accepted, inProgress, completed } 객체도 런타임에서 자동 변환
statusMappings?: StatusValueMapping[];
}
export type TimelineStatusSemantic = "pending" | "active" | "done";
export interface StatusValueMapping {
dbValue: string; // DB에 저장된 실제 값 (또는 파생 상태의 식별값)
label: string; // 화면에 보이는 이름
semantic: TimelineStatusSemantic; // 타임라인 색상 결정 (pending=회색, active=파랑, done=초록)
isDerived?: boolean; // true면 DB에 없는 자동 판별 상태 (이전 공정 완료 시 변환)
}
export interface CardCellDefinitionV2 {
id: string;
row: number;
col: number;
rowSpan: number;
colSpan: number;
type: CardCellType;
// 공통
columnName?: string;
label?: string;
labelPosition?: "top" | "left";
fontSize?: "xs" | "sm" | "md" | "lg";
fontWeight?: "normal" | "medium" | "bold";
textColor?: string;
align?: "left" | "center" | "right";
verticalAlign?: "top" | "middle" | "bottom";
// field 타입 전용 (CardFieldBinding 흡수)
valueType?: "column" | "formula";
formulaLeft?: string;
formulaOperator?: "+" | "-" | "*" | "/";
formulaRight?: string;
formulaRightType?: "input" | "column";
unit?: string;
// image 타입 전용
defaultImage?: string;
// button 타입 전용
buttonAction?: ButtonMainAction;
buttonVariant?: ButtonVariant;
buttonConfirm?: ConfirmConfig;
// number-input 타입 전용
inputUnit?: string;
limitColumn?: string;
autoInitMax?: boolean;
// cart-button 타입 전용
cartLabel?: string;
cartCancelLabel?: string;
cartIconType?: "lucide" | "emoji";
cartIconValue?: string;
// status-badge 타입 전용
statusColumn?: string;
statusMap?: Array<{ value: string; label: string; color: string }>;
// timeline 타입 전용: 공정 데이터 소스 설정
timelineSource?: TimelineDataSource;
processColumn?: string;
processStatusColumn?: string;
currentHighlight?: boolean;
visibleCount?: number;
timelinePriority?: "before" | "after";
showDetailModal?: boolean;
// action-buttons 타입 전용 (신규: 버튼 중심 구조)
actionButtons?: ActionButtonDef[];
// action-buttons 타입 전용 (구: 조건 중심 구조, 하위호환)
actionRules?: Array<{
whenStatus: string;
buttons: Array<ActionButtonConfig>;
}>;
// footer-status 타입 전용
footerLabel?: string;
footerStatusColumn?: string;
footerStatusMap?: Array<{ value: string; label: string; color: string }>;
showTopBorder?: boolean;
}
export interface ActionButtonUpdate {
column: string;
value?: string;
valueType: "static" | "currentUser" | "currentTime" | "columnRef";
}
// 액션 버튼 클릭 시 동작 모드
export type ActionButtonClickMode = "status-change" | "modal-open" | "select-mode";
// 액션 버튼 개별 설정
export interface ActionButtonConfig {
label: string;
variant: ButtonVariant;
taskPreset: string;
confirm?: ConfirmConfig;
targetTable?: string;
confirmMessage?: string;
allowMultiSelect?: boolean;
updates?: ActionButtonUpdate[];
clickMode?: ActionButtonClickMode;
selectModeConfig?: SelectModeConfig;
}
// 선택 모드 설정
export interface SelectModeConfig {
filterStatus?: string;
buttons: Array<SelectModeButtonConfig>;
}
// 선택 모드 하단 버튼 설정
export interface SelectModeButtonConfig {
label: string;
variant: ButtonVariant;
clickMode: "status-change" | "modal-open" | "cancel-select";
targetTable?: string;
updates?: ActionButtonUpdate[];
confirmMessage?: string;
modalScreenId?: string;
}
// ===== 버튼 중심 구조 (신규) =====
export interface ActionButtonShowCondition {
type: "timeline-status" | "column-value" | "always";
value?: string;
column?: string;
unmatchBehavior?: "hidden" | "disabled";
}
export interface ActionButtonClickAction {
type: "immediate" | "select-mode" | "modal-open";
targetTable?: string;
updates?: ActionButtonUpdate[];
confirmMessage?: string;
selectModeButtons?: SelectModeButtonConfig[];
modalScreenId?: string;
// 외부 테이블 조인 설정 (DB 직접 선택 시)
joinConfig?: {
sourceColumn: string; // 메인 테이블의 FK 컬럼
targetColumn: string; // 외부 테이블의 매칭 컬럼
};
}
export interface ActionButtonDef {
label: string;
variant: ButtonVariant;
showCondition?: ActionButtonShowCondition;
/** 단일 액션 (하위호환) 또는 다중 액션 체이닝 */
clickAction: ActionButtonClickAction;
clickActions?: ActionButtonClickAction[];
}
export interface CardGridConfigV2 {
rows: number;
cols: number;
colWidths: string[];
rowHeights?: string[];
gap: number;
showCellBorder: boolean;
cells: CardCellDefinitionV2[];
}
// ----- V2 카드 선택 동작 -----
export type V2CardClickAction = "none" | "publish" | "navigate" | "modal-open";
export interface V2CardClickModalConfig {
screenId: string;
modalTitle?: string;
condition?: {
type: "timeline-status" | "column-value" | "always";
value?: string;
column?: string;
};
}
// ----- V2 오버플로우 설정 -----
export interface V2OverflowConfig {
mode: "loadMore" | "pagination";
visibleCount: number;
loadMoreCount?: number;
pageSize?: number;
}
// ----- pop-card-list-v2 전체 설정 -----
export interface PopCardListV2Config {
dataSource: CardListDataSource;
cardGrid: CardGridConfigV2;
selectedColumns?: string[];
gridColumns?: number;
gridRows?: number;
scrollDirection?: CardScrollDirection;
/** @deprecated 열 수(gridColumns)로 카드 크기 결정. 하위 호환용 */
cardSize?: CardSize;
cardGap?: number;
overflow?: V2OverflowConfig;
cardClickAction?: V2CardClickAction;
cardClickModalConfig?: V2CardClickModalConfig;
/** 연결된 필터 값이 전달되기 전까지 데이터 비표시 */
hideUntilFiltered?: boolean;
responsiveDisplay?: CardResponsiveConfig;
inputField?: CardInputFieldConfig;
packageConfig?: CardPackageConfig;
cartAction?: CardCartActionConfig;
cartListMode?: CartListModeConfig;
saveMapping?: CardListSaveMapping;
}
/** 카드 컴포넌트가 하위 필터 적용 시 주입하는 가상 컬럼 키 */
export const VIRTUAL_SUB_STATUS = "__subStatus__" as const;
export const VIRTUAL_SUB_SEMANTIC = "__subSemantic__" as const;
export const VIRTUAL_SUB_PROCESS = "__subProcessName__" as const;
export const VIRTUAL_SUB_SEQ = "__subSeqNo__" as const;
+18 -1
View File
@@ -78,13 +78,30 @@ export function loadTabCache(tabId: string): TabCacheData | null {
}
/**
*
* .
* tab-cache-{tabId} (tableState_, pageSize_, filterSettings_, groupSettings_) .
*/
export function clearTabCache(tabId: string): void {
if (typeof window === "undefined") return;
try {
sessionStorage.removeItem(CACHE_PREFIX + tabId);
const suffix = `_${tabId}_`;
const keysToRemove: string[] = [];
for (let i = 0; i < sessionStorage.length; i++) {
const key = sessionStorage.key(i);
if (
key &&
(key.startsWith("tableState" + suffix) ||
key.startsWith("pageSize" + suffix) ||
key.startsWith("filterSettings" + suffix) ||
key.startsWith("groupSettings" + suffix))
) {
keysToRemove.push(key);
}
}
keysToRemove.forEach((k) => sessionStorage.removeItem(k));
} catch {
// ignore
}
+12 -10
View File
@@ -689,11 +689,10 @@ export class ButtonActionExecutor {
return false;
}
// 🆕 렉 구조 등록 화면 감지 (warehouse_location 테이블 + floor/zone 필드 있음 + 렉 구조 데이터 없음)
// 이 경우 일반 저장을 차단하고 미리보기 생성을 요구
// 렉 구조 등록 화면 감지 (warehouse_location 테이블 + zone 필드 있음 + 렉 구조 데이터 없음)
// floor는 선택 입력이므로 감지 조건에서 제외
const isRackStructureScreen =
context.tableName === "warehouse_location" &&
context.formData?.floor &&
context.formData?.zone &&
!rackStructureLocations;
@@ -2085,15 +2084,18 @@ export class ButtonActionExecutor {
const floor = firstLocation.floor;
const zone = firstLocation.zone;
if (warehouseCode && floor && zone) {
if (warehouseCode && zone) {
try {
// search 파라미터를 사용하여 백엔드에서 필터링 (filters는 백엔드에서 처리 안됨)
const searchParams: Record<string, any> = {
warehouse_code: { value: warehouseCode, operator: "equals" },
zone: { value: zone, operator: "equals" },
};
if (floor) {
searchParams.floor = { value: floor, operator: "equals" };
}
const existingResponse = await DynamicFormApi.getTableData(tableName, {
search: {
warehouse_code: { value: warehouseCode, operator: "equals" },
floor: { value: floor, operator: "equals" },
zone: { value: zone, operator: "equals" },
},
search: searchParams,
page: 1,
pageSize: 1000,
});
+37 -5
View File
@@ -265,6 +265,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@@ -306,6 +307,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@@ -339,6 +341,7 @@
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
@@ -3054,6 +3057,7 @@
"resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.4.0.tgz",
"integrity": "sha512-k4iu1R6e5D54918V4sqmISUkI5OgTw3v7/sDRKEC632Wd5g2WBtUS5gyG63X0GJO/HZUj1tsjSXfyzwrUHZl1g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.17.8",
"@types/react-reconciler": "^0.32.0",
@@ -3707,6 +3711,7 @@
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.6.tgz",
"integrity": "sha512-gB1sljYjcobZKxjPbKSa31FUTyr+ROaBdoH+wSSs9Dk+yDCmMs+TkTV3PybRRVLC7ax7q0erJ9LvRWnMktnRAw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@tanstack/query-core": "5.90.6"
},
@@ -3774,6 +3779,7 @@
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.1.tgz",
"integrity": "sha512-nkerkl8syHj44ZzAB7oA2GPmmZINKBKCa79FuNvmGJrJ4qyZwlkDzszud23YteFZEytbc87kVd/fP76ROS6sLg==",
"license": "MIT",
"peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
@@ -4087,6 +4093,7 @@
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.1.tgz",
"integrity": "sha512-ijKo3+kIjALthYsnBmkRXAuw2Tswd9gd7BUR5OMfIcjGp8v576vKxOxrRfuYiUM78GPt//P0sVc1WV82H5N0PQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"prosemirror-changeset": "^2.3.0",
"prosemirror-collab": "^1.3.1",
@@ -6587,6 +6594,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -6597,6 +6605,7 @@
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -6639,6 +6648,7 @@
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.180.0.tgz",
"integrity": "sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@dimforge/rapier3d-compat": "~0.12.0",
"@tweenjs/tween.js": "~23.1.3",
@@ -6721,6 +6731,7 @@
"integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.46.2",
"@typescript-eslint/types": "8.46.2",
@@ -7353,6 +7364,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -8503,7 +8515,8 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/d3": {
"version": "7.9.0",
@@ -8825,6 +8838,7 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"peer": true,
"engines": {
"node": ">=12"
}
@@ -9584,6 +9598,7 @@
"integrity": "sha512-iy2GE3MHrYTL5lrCtMZ0X1KLEKKUjmK0kzwcnefhR66txcEmXZD2YWgR5GNdcEwkNx3a0siYkSvl0vIC+Svjmg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -9672,6 +9687,7 @@
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"eslint-config-prettier": "bin/cli.js"
},
@@ -9773,6 +9789,7 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -10944,6 +10961,7 @@
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
@@ -11724,7 +11742,8 @@
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause"
"license": "BSD-2-Clause",
"peer": true
},
"node_modules/levn": {
"version": "0.4.1",
@@ -13063,6 +13082,7 @@
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -13356,6 +13376,7 @@
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
"license": "MIT",
"peer": true,
"dependencies": {
"orderedmap": "^2.0.0"
}
@@ -13385,6 +13406,7 @@
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
"license": "MIT",
"peer": true,
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-transform": "^1.0.0",
@@ -13433,6 +13455,7 @@
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.4.tgz",
"integrity": "sha512-WkKgnyjNncri03Gjaz3IFWvCAE94XoiEgvtr0/r2Xw7R8/IjK3sKLSiDoCHWcsXSAinVaKlGRZDvMCsF1kbzjA==",
"license": "MIT",
"peer": true,
"dependencies": {
"prosemirror-model": "^1.20.0",
"prosemirror-state": "^1.0.0",
@@ -13636,6 +13659,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -13705,6 +13729,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.26.0"
},
@@ -13755,6 +13780,7 @@
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.0.tgz",
"integrity": "sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
@@ -13787,7 +13813,8 @@
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/react-leaflet": {
"version": "5.0.0",
@@ -14095,6 +14122,7 @@
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
@@ -14117,7 +14145,8 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/recharts/node_modules/redux-thunk": {
"version": "3.1.0",
@@ -15147,7 +15176,8 @@
"version": "0.180.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz",
"integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/three-mesh-bvh": {
"version": "0.8.3",
@@ -15235,6 +15265,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -15583,6 +15614,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
+1
View File
@@ -149,6 +149,7 @@ export const useTabStore = create<TabState>()(
},
refreshTab: (tabId) => {
clearTabCache(tabId);
set((state) => ({
refreshKeys: { ...state.refreshKeys, [tabId]: (state.refreshKeys[tabId] || 0) + 1 },
}));
+1
View File
@@ -14,6 +14,7 @@ export interface LoginResponse {
token?: string;
userInfo?: any;
firstMenuPath?: string | null;
popLandingPath?: string | null;
};
errorCode?: string;
}