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:
parent
461ff6dbf7
commit
da9bce2301
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
{/* 기본 표시 수 */}
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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="카드 목록 선택" />
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue