fix: POP 장바구니 입고 워크플로우 수정 및 접근성/UX 개선

장바구니(cart) 기반 구매입고 흐름에서 버튼 동작, 검색 접근성,
필터 안내 메시지 기능을 수정하여 실제 운영 시나리오에서의
안정성을 확보한다.
[pop-button 장바구니 모드 판단 수정]
- isCartMode: v1 preset뿐 아니라 v2 tasks에 cart-save가 포함된
  경우에도 장바구니 모드로 인식하도록 개선
- resolvedCartScreenId: v2 tasks의 cartScreenId도 참조
- 장바구니 모드 분기를 v2 tasks 처리보다 먼저 실행하여
  cart-save 버튼이 정상 동작하도록 순서 변경
[pop-search 아이콘 카드 접근성]
- IconView의 div 카드에 role="button", tabIndex={0},
  onKeyDown(Enter/Space) 추가
- 브라우저 자동화 및 키보드 사용자가 아이콘 카드를
  인터랙티브 요소로 인식 가능
[필터 필수 안내 메시지 기능]
- pop-card-list(장바구니 목록): requireFilter, requireFilterMessage
  설정 추가, 필터 미선택 시 커스텀 안내 문구 표시
- pop-card-list-v2(MES 공정흐름): hideUntilFilteredMessage
  설정 추가, 기존 필터 전 숨김에 커스텀 문구 지원
- 양쪽 설정 패널에 안내 문구 입력 UI 추가
[원본 화면 선택 Combobox 전환]
- PopCardListConfig 장바구니 모드의 원본 화면 선택을
  Select에서 검색 가능한 Combobox로 변경
- 로그인 계정의 companyCode로 화면 목록 필터링 적용
This commit is contained in:
SeongHyun Kim 2026-03-23 10:26:06 +09:00
parent 461ff6dbf7
commit da9bce2301
7 changed files with 171 additions and 74 deletions

View File

@ -651,8 +651,12 @@ export function PopButtonComponent({ config, label, isDesignMode, screenId, comp
const { subscribe, publish } = usePopEvent(screenId || "default");
// 장바구니 모드 상태
const isCartMode = config?.preset === "cart";
// 장바구니 모드 상태 (v1 preset 또는 v2 tasks에 cart-save가 있으면 활성)
const v2Tasks = (config && "tasks" in config && Array.isArray((config as any).tasks))
? (config as any).tasks as PopButtonTask[]
: null;
const hasCartSaveTask = !!v2Tasks?.some((t) => t.type === "cart-save");
const isCartMode = config?.preset === "cart" || hasCartSaveTask;
const isInboundConfirmMode = config?.preset === "inbound-confirm";
const [cartCount, setCartCount] = useState(0);
const [cartIsDirty, setCartIsDirty] = useState(false);
@ -746,8 +750,10 @@ export function PopButtonComponent({ config, label, isDesignMode, screenId, comp
}, [isCartMode, componentId, subscribe]);
// 저장 완료 수신 (카드 목록에서 saveToDb 완료 후 전달)
const cartScreenIdRef = React.useRef(config?.cart?.cartScreenId);
cartScreenIdRef.current = config?.cart?.cartScreenId;
const resolvedCartScreenId = config?.cart?.cartScreenId
|| v2Tasks?.find((t) => t.type === "cart-save")?.cartScreenId;
const cartScreenIdRef = React.useRef(resolvedCartScreenId);
cartScreenIdRef.current = resolvedCartScreenId;
useEffect(() => {
if (!isCartMode || !componentId) return;
@ -990,7 +996,28 @@ export function PopButtonComponent({ config, label, isDesignMode, screenId, comp
return;
}
// v2 경로: tasks 배열이 있으면 새 실행 엔진 사용
// 장바구니 모드 (v1 preset: "cart" 또는 v2 tasks에 cart-save 포함)
if (isCartMode) {
if (cartCount === 0 && !cartIsDirty) {
toast.info("장바구니가 비어 있습니다.");
return;
}
if (cartIsDirty) {
setShowCartConfirm(true);
} else {
const targetScreenId = resolvedCartScreenId;
if (targetScreenId) {
const cleanId = String(targetScreenId).replace(/^.*\/(\d+)$/, "$1").trim();
window.location.href = `/pop/screens/${cleanId}`;
} else {
toast.info("장바구니 화면이 설정되지 않았습니다.");
}
}
return;
}
// v2 경로: tasks 배열이 있으면 새 실행 엔진 사용 (cart-save 제외)
if (v2Config) {
if (v2Config.confirm?.enabled) {
setShowInboundConfirm(true);
@ -1012,27 +1039,6 @@ export function PopButtonComponent({ config, label, isDesignMode, screenId, comp
return;
}
// 장바구니 모드: isDirty 여부에 따라 분기
if (isCartMode) {
if (cartCount === 0 && !cartIsDirty) {
toast.info("장바구니가 비어 있습니다.");
return;
}
if (cartIsDirty) {
setShowCartConfirm(true);
} else {
const targetScreenId = config?.cart?.cartScreenId;
if (targetScreenId) {
const cleanId = targetScreenId.replace(/^.*\/(\d+)$/, "$1").trim();
window.location.href = `/pop/screens/${cleanId}`;
} else {
toast.info("장바구니 화면이 설정되지 않았습니다.");
}
}
return;
}
const action = config?.action;
if (!action) return;

View File

@ -8,7 +8,7 @@
import React, { useEffect, useState, useRef, useMemo, useCallback } from "react";
import {
Loader2, ChevronDown, ChevronUp, ChevronLeft, ChevronRight, Check, X,
Loader2, ChevronDown, ChevronUp, ChevronLeft, ChevronRight, Check, X, Search,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@ -1161,8 +1161,11 @@ export function PopCardListV2Component({
<p className="text-sm text-muted-foreground"> .</p>
</div>
) : config?.hideUntilFiltered && effectiveExternalFilters.size === 0 ? (
<div className="flex flex-1 items-center justify-center p-4">
<p className="text-sm text-muted-foreground"> .</p>
<div className="flex flex-1 flex-col items-center justify-center gap-3 p-6">
<Search className="h-8 w-8 text-muted-foreground/50" />
<p className="text-center text-sm text-muted-foreground">
{config.hideUntilFilteredMessage || "필터를 먼저 선택해주세요."}
</p>
</div>
) : loading ? (
<div className="flex flex-1 items-center justify-center p-4">

View File

@ -3250,9 +3250,20 @@ function TabActions({
/>
</div>
{cfg.hideUntilFiltered && (
<p className="text-[9px] text-muted-foreground -mt-2 pl-1">
.
</p>
<div className="space-y-1.5 -mt-1">
<p className="text-[9px] text-muted-foreground pl-1">
.
</p>
<div>
<Label className="text-[9px] text-muted-foreground"> </Label>
<Input
value={cfg.hideUntilFilteredMessage || ""}
onChange={(e) => onUpdate({ hideUntilFilteredMessage: e.target.value })}
placeholder="필터를 먼저 선택해주세요."
className="h-7 text-[10px]"
/>
</div>
</div>
)}
{/* 기본 표시 수 */}

View File

@ -14,7 +14,7 @@ import { useRouter } from "next/navigation";
import {
Loader2, ChevronDown, ChevronUp, ChevronLeft, ChevronRight,
ShoppingCart, X, Package, Truck, Box, Archive, Heart, Star,
Trash2,
Trash2, Search,
type LucideIcon,
} from "lucide-react";
import { toast } from "sonner";
@ -770,6 +770,13 @@ export function PopCardListComponent({
.
</p>
</div>
) : !isCartListMode && config?.requireFilter && externalFilters.size === 0 ? (
<div className="flex flex-1 flex-col items-center justify-center gap-3 p-6">
<Search className="h-8 w-8 text-muted-foreground/50" />
<p className="text-center text-sm text-muted-foreground">
{config.requireFilterMessage || "필터를 먼저 선택해주세요."}
</p>
</div>
) : loading ? (
<div className="flex flex-1 items-center justify-center rounded-md border bg-muted/30 p-4">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />

View File

@ -9,8 +9,11 @@
*/
import React, { useState, useEffect, useMemo } from "react";
import { ChevronDown, ChevronRight, Plus, Trash2, Database, Check } from "lucide-react";
import { ChevronDown, ChevronRight, Plus, Trash2, Database, Check, ChevronsUpDown } from "lucide-react";
import { useCollapsibleSections } from "@/hooks/pop/useCollapsibleSections";
import { useAuth } from "@/hooks/useAuth";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import type { GridMode } from "@/components/pop/designer/types/pop-layout";
import { GRID_BREAKPOINTS } from "@/components/pop/designer/types/pop-layout";
import { Button } from "@/components/ui/button";
@ -431,6 +434,32 @@ function BasicSettingsTab({
</CollapsibleSection>
)}
{/* 필터 필수 설정 (장바구니 모드 아닐 때만) */}
{!isCartListMode && dataSource.tableName && (
<CollapsibleSection sectionKey="basic-require-filter" title="필터 필수" sections={sections}>
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-[10px]"> </Label>
<Switch
checked={!!config.requireFilter}
onCheckedChange={(checked) => onUpdate({ requireFilter: checked })}
/>
</div>
{config.requireFilter && (
<div>
<Label className="text-[10px] text-muted-foreground"> </Label>
<Input
value={config.requireFilterMessage || ""}
onChange={(e) => onUpdate({ requireFilterMessage: e.target.value })}
placeholder="필터를 먼저 선택해주세요."
className="h-7 text-[10px]"
/>
</div>
)}
</div>
</CollapsibleSection>
)}
{/* 저장 매핑 (장바구니 모드일 때만) */}
{isCartListMode && (
<CollapsibleSection
@ -842,28 +871,29 @@ function CartListModeSection({
onUpdate: (config: CartListModeConfig) => void;
}) {
const mode: CartListModeConfig = cartListMode || { enabled: false };
const [screens, setScreens] = useState<{ id: number; name: string }[]>([]);
const [screens, setScreens] = useState<{ id: number; name: string; code: string }[]>([]);
const [sourceCardLists, setSourceCardLists] = useState<SourceCardListInfo[]>([]);
const [loadingComponents, setLoadingComponents] = useState(false);
const [screenOpen, setScreenOpen] = useState(false);
const { companyCode } = useAuth();
// 화면 목록 로드
useEffect(() => {
screenApi
.getScreens({ size: 500 })
.getScreens({ size: 500, companyCode: companyCode || undefined })
.then((res) => {
if (res?.data) {
setScreens(
res.data.map((s) => ({
id: s.screenId,
name: s.screenName || `화면 ${s.screenId}`,
code: s.screenCode || "",
}))
);
}
})
.catch(() => {});
}, []);
}, [companyCode]);
// 원본 화면 선택 시 -> 해당 화면의 pop-card-list 컴포넌트 목록 로드
useEffect(() => {
if (!mode.sourceScreenId) {
setSourceCardLists([]);
@ -889,22 +919,7 @@ function CartListModeSection({
.finally(() => setLoadingComponents(false));
}, [mode.sourceScreenId]);
const handleScreenChange = (val: string) => {
const screenId = val === "__none__" ? undefined : Number(val);
onUpdate({ ...mode, sourceScreenId: screenId });
};
const handleComponentSelect = (val: string) => {
if (val === "__none__") {
onUpdate({ ...mode, sourceComponentId: undefined });
return;
}
const compId = val.startsWith("__comp_") ? val.replace("__comp_", "") : val;
const found = sourceCardLists.find((c) => c.componentId === compId);
if (found) {
onUpdate({ ...mode, sourceComponentId: found.componentId });
}
};
const selectedScreen = screens.find((s) => s.id === mode.sourceScreenId);
return (
<div className="space-y-3">
@ -923,28 +938,69 @@ function CartListModeSection({
{mode.enabled && (
<>
{/* 원본 화면 선택 */}
{/* 원본 화면 선택 (검색 가능 Combobox) */}
<div>
<Label className="text-[10px] text-muted-foreground"> </Label>
<Select
value={mode.sourceScreenId ? String(mode.sourceScreenId) : "__none__"}
onValueChange={handleScreenChange}
>
<SelectTrigger className="mt-1 h-7 text-xs">
<SelectValue placeholder="화면 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> </SelectItem>
{screens.map((s) => (
<SelectItem key={s.id} value={String(s.id)}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Popover open={screenOpen} onOpenChange={setScreenOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={screenOpen}
className="mt-1 h-7 w-full justify-between text-xs"
>
{selectedScreen
? `${selectedScreen.name} (${selectedScreen.id})`
: "화면 검색..."}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
align="start"
>
<Command>
<CommandInput placeholder="화면 이름 또는 ID 검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="py-2 text-center text-xs">
.
</CommandEmpty>
<CommandGroup>
{screens.map((s) => (
<CommandItem
key={s.id}
value={`${s.name} ${s.id} ${s.code}`}
onSelect={() => {
onUpdate({
...mode,
sourceScreenId: mode.sourceScreenId === s.id ? undefined : s.id,
sourceComponentId: mode.sourceScreenId === s.id ? undefined : mode.sourceComponentId,
});
setScreenOpen(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
mode.sourceScreenId === s.id ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex flex-col">
<span>{s.name}</span>
<span className="text-[9px] text-muted-foreground">ID: {s.id}</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{/* 원본 컴포넌트 선택 (원본 화면에서 자동 로드) */}
{/* 원본 컴포넌트 선택 */}
{mode.sourceScreenId && (
<div>
<Label className="text-[10px] text-muted-foreground"> </Label>
@ -959,7 +1015,14 @@ function CartListModeSection({
) : (
<Select
value={mode.sourceComponentId ? `__comp_${mode.sourceComponentId}` : "__none__"}
onValueChange={handleComponentSelect}
onValueChange={(val) => {
if (val === "__none__") {
onUpdate({ ...mode, sourceComponentId: undefined });
} else {
const compId = val.replace("__comp_", "");
onUpdate({ ...mode, sourceComponentId: compId });
}
}}
>
<SelectTrigger className="mt-1 h-7 text-xs">
<SelectValue placeholder="카드 목록 선택" />

View File

@ -1026,8 +1026,11 @@ function IconView({
return (
<div
key={i}
role="button"
tabIndex={0}
className="flex w-20 cursor-pointer flex-col items-center gap-1.5 rounded-lg p-2 transition-colors hover:bg-accent"
onClick={() => onSelect(row)}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") onSelect(row); }}
>
<div className={cn("flex h-14 w-14 items-center justify-center rounded-xl text-xl font-bold text-white", color)}>
{firstChar}

View File

@ -721,6 +721,9 @@ export interface PopCardListConfig {
cartListMode?: CartListModeConfig;
saveMapping?: CardListSaveMapping;
requireFilter?: boolean;
requireFilterMessage?: string;
}
// =============================================
@ -990,6 +993,7 @@ export interface PopCardListV2Config {
cardClickModalConfig?: V2CardClickModalConfig;
/** 연결된 필터 값이 전달되기 전까지 데이터 비표시 */
hideUntilFiltered?: boolean;
hideUntilFilteredMessage?: string;
responsiveDisplay?: CardResponsiveConfig;
inputField?: CardInputFieldConfig;
packageConfig?: CardPackageConfig;