feat: 낙관적 잠금 + 소유자 기반 액션 제어 + 디자이너 설정 UI
동시 접수 충돌 방지(preCondition WHERE + 409 에러), 소유자 일치 시에만 버튼 활성화(owner-match showCondition), 본인 카드 우선 정렬(ownerSortColumn)을 구현하고 디자이너에서 설정할 수 있는 UI 3종을 추가한다. [백엔드] - popActionRoutes: TaskBody에 preCondition 추가, data-update WHERE 조건 삽입, rowCount=0 시 409 Conflict 반환 (isPreConditionFail) [프론트엔드 - 런타임] - types.ts: ActionPreCondition 인터페이스, owner-match 타입, ownerSortColumn 필드 - cell-renderers: evaluateShowCondition에 owner-match 분기 + currentUserId prop - PopCardListV2Component: useAuth 연동, preCondition 전달/409 처리, ownerSortColumn 기반 카드 정렬, currentUserId 하위 전달 [프론트엔드 - 디자이너 설정 UI] - PopCardListV2Config: showCondition 드롭다운에 "소유자 일치" 옵션 + 컬럼 선택, ImmediateActionEditor에 "사전 조건(중복 방지)" 토글 + 검증 컬럼/기대값/실패 메시지, TabActions에 "소유자 우선 정렬" 컬럼 드롭다운
This commit is contained in:
parent
710d9fe212
commit
a2c532c7c7
|
|
@ -104,6 +104,11 @@ interface TaskBody {
|
|||
manualItemField?: string;
|
||||
manualPkColumn?: string;
|
||||
cartScreenId?: string;
|
||||
preCondition?: {
|
||||
column: string;
|
||||
expectedValue: string;
|
||||
failMessage?: string;
|
||||
};
|
||||
}
|
||||
|
||||
function resolveStatusValue(
|
||||
|
|
@ -334,14 +339,30 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
|
|||
const item = items[i] ?? {};
|
||||
const resolved = resolveStatusValue("conditional", task.fixedValue ?? "", task.conditionalValue, item);
|
||||
const autoUpdated = task.targetColumn !== "updated_date" ? `, "updated_date" = NOW()` : "";
|
||||
await client.query(
|
||||
`UPDATE "${task.targetTable}" SET "${task.targetColumn}" = $1${autoUpdated} WHERE company_code = $2 AND "${pkColumn}" = $3`,
|
||||
[resolved, companyCode, lookupValues[i]],
|
||||
let condWhere = `WHERE company_code = $2 AND "${pkColumn}" = $3`;
|
||||
const condParams: unknown[] = [resolved, companyCode, lookupValues[i]];
|
||||
if (task.preCondition?.column && task.preCondition?.expectedValue) {
|
||||
if (!isSafeIdentifier(task.preCondition.column)) throw new Error(`유효하지 않은 preCondition 컬럼명: ${task.preCondition.column}`);
|
||||
condWhere += ` AND "${task.preCondition.column}" = $4`;
|
||||
condParams.push(task.preCondition.expectedValue);
|
||||
}
|
||||
const condResult = await client.query(
|
||||
`UPDATE "${task.targetTable}" SET "${task.targetColumn}" = $1${autoUpdated} ${condWhere}`,
|
||||
condParams,
|
||||
);
|
||||
if (task.preCondition && condResult.rowCount === 0) {
|
||||
const err = new Error(task.preCondition.failMessage || "조건이 일치하지 않아 처리할 수 없습니다.");
|
||||
(err as any).isPreConditionFail = true;
|
||||
throw err;
|
||||
}
|
||||
processedCount++;
|
||||
}
|
||||
} else if (opType === "db-conditional") {
|
||||
// DB 컬럼 간 비교 후 값 판정 (CASE WHEN col_a >= col_b THEN '완료' ELSE '진행중')
|
||||
if (task.preCondition) {
|
||||
logger.warn("[pop/execute-action] db-conditional에는 preCondition 미지원, 무시됨", {
|
||||
taskId: task.id, preCondition: task.preCondition,
|
||||
});
|
||||
}
|
||||
if (!task.compareColumn || !task.compareOperator || !task.compareWith) break;
|
||||
if (!isSafeIdentifier(task.compareColumn) || !isSafeIdentifier(task.compareWith)) break;
|
||||
|
||||
|
|
@ -392,10 +413,24 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
|
|||
}
|
||||
|
||||
const autoUpdatedDate = task.targetColumn !== "updated_date" ? `, "updated_date" = NOW()` : "";
|
||||
await client.query(
|
||||
`UPDATE "${task.targetTable}" SET ${setSql}${autoUpdatedDate} WHERE company_code = $2 AND "${pkColumn}" = $3`,
|
||||
[value, companyCode, lookupValues[i]],
|
||||
let whereSql = `WHERE company_code = $2 AND "${pkColumn}" = $3`;
|
||||
const queryParams: unknown[] = [value, companyCode, lookupValues[i]];
|
||||
if (task.preCondition?.column && task.preCondition?.expectedValue) {
|
||||
if (!isSafeIdentifier(task.preCondition.column)) {
|
||||
throw new Error(`유효하지 않은 preCondition 컬럼명: ${task.preCondition.column}`);
|
||||
}
|
||||
whereSql += ` AND "${task.preCondition.column}" = $4`;
|
||||
queryParams.push(task.preCondition.expectedValue);
|
||||
}
|
||||
const updateResult = await client.query(
|
||||
`UPDATE "${task.targetTable}" SET ${setSql}${autoUpdatedDate} ${whereSql}`,
|
||||
queryParams,
|
||||
);
|
||||
if (task.preCondition && updateResult.rowCount === 0) {
|
||||
const err = new Error(task.preCondition.failMessage || "조건이 일치하지 않아 처리할 수 없습니다.");
|
||||
(err as any).isPreConditionFail = true;
|
||||
throw err;
|
||||
}
|
||||
processedCount++;
|
||||
}
|
||||
}
|
||||
|
|
@ -746,6 +781,16 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
|
|||
});
|
||||
} catch (error: any) {
|
||||
await client.query("ROLLBACK");
|
||||
|
||||
if (error.isPreConditionFail) {
|
||||
logger.warn("[pop/execute-action] preCondition 실패", { message: error.message });
|
||||
return res.status(409).json({
|
||||
success: false,
|
||||
message: error.message,
|
||||
errorCode: "PRE_CONDITION_FAIL",
|
||||
});
|
||||
}
|
||||
|
||||
logger.error("[pop/execute-action] 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import type {
|
|||
TimelineDataSource,
|
||||
ActionButtonUpdate,
|
||||
ActionButtonClickAction,
|
||||
QuantityInputConfig,
|
||||
StatusValueMapping,
|
||||
SelectModeConfig,
|
||||
SelectModeButtonConfig,
|
||||
|
|
@ -47,6 +48,7 @@ import { screenApi } from "@/lib/api/screen";
|
|||
import { apiClient } from "@/lib/api/client";
|
||||
import { usePopEvent } from "@/hooks/pop/usePopEvent";
|
||||
import { useCartSync } from "@/hooks/pop/useCartSync";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { NumberInputModal } from "../pop-card-list/NumberInputModal";
|
||||
import { renderCellV2 } from "./cell-renderers";
|
||||
import type { PopLayoutDataV5 } from "@/components/pop/designer/types/pop-layout";
|
||||
|
|
@ -56,6 +58,32 @@ const PopViewerWithModals = dynamic(() => import("@/components/pop/viewer/PopVie
|
|||
|
||||
type RowData = Record<string, unknown>;
|
||||
|
||||
function calculateMaxQty(
|
||||
row: RowData,
|
||||
processId: string | number | undefined,
|
||||
cfg?: QuantityInputConfig,
|
||||
): number {
|
||||
if (!cfg) return 999999;
|
||||
const maxVal = cfg.maxColumn ? Number(row[cfg.maxColumn]) || 999999 : 999999;
|
||||
if (!cfg.currentColumn) return maxVal;
|
||||
|
||||
const processFlow = row.__processFlow__ as Array<{
|
||||
isCurrent: boolean;
|
||||
processId?: string | number;
|
||||
rawData?: Record<string, unknown>;
|
||||
}> | undefined;
|
||||
|
||||
const currentProcess = processId
|
||||
? processFlow?.find((p) => String(p.processId) === String(processId))
|
||||
: processFlow?.find((p) => p.isCurrent);
|
||||
|
||||
if (currentProcess?.rawData) {
|
||||
const currentVal = Number(currentProcess.rawData[cfg.currentColumn]) || 0;
|
||||
return Math.max(0, maxVal - currentVal);
|
||||
}
|
||||
return maxVal;
|
||||
}
|
||||
|
||||
// cart_items 행 파싱 (pop-card-list에서 그대로 차용)
|
||||
function parseCartRow(dbRow: Record<string, unknown>): Record<string, unknown> {
|
||||
let rowData: Record<string, unknown> = {};
|
||||
|
|
@ -113,6 +141,7 @@ export function PopCardListV2Component({
|
|||
}: PopCardListV2ComponentProps) {
|
||||
const { subscribe, publish } = usePopEvent(screenId || "default");
|
||||
const router = useRouter();
|
||||
const { userId: currentUserId } = useAuth();
|
||||
|
||||
const isCartListMode = config?.cartListMode?.enabled === true;
|
||||
const [inheritedConfig, setInheritedConfig] = useState<Partial<PopCardListV2Config> | null>(null);
|
||||
|
|
@ -469,7 +498,7 @@ export function PopCardListV2Component({
|
|||
type: "data-update" as const,
|
||||
targetTable: btnConfig.targetTable!,
|
||||
targetColumn: u.column,
|
||||
operationType: "assign" as const,
|
||||
operationType: (u.operationType || "assign") as "assign" | "add" | "subtract",
|
||||
valueSource: "fixed" as const,
|
||||
fixedValue: u.valueType === "static" ? (u.value ?? "") :
|
||||
u.valueType === "currentUser" ? "__CURRENT_USER__" :
|
||||
|
|
@ -619,11 +648,28 @@ export function PopCardListV2Component({
|
|||
|
||||
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const ownerSortColumn = config?.ownerSortColumn;
|
||||
|
||||
const displayCards = useMemo(() => {
|
||||
if (!isExpanded) return filteredRows.slice(0, visibleCardCount);
|
||||
let source = filteredRows;
|
||||
|
||||
if (ownerSortColumn && currentUserId) {
|
||||
const mine: RowData[] = [];
|
||||
const others: RowData[] = [];
|
||||
for (const row of source) {
|
||||
if (String(row[ownerSortColumn] ?? "") === currentUserId) {
|
||||
mine.push(row);
|
||||
} else {
|
||||
others.push(row);
|
||||
}
|
||||
}
|
||||
source = [...mine, ...others];
|
||||
}
|
||||
|
||||
if (!isExpanded) return source.slice(0, visibleCardCount);
|
||||
const start = (currentPage - 1) * expandedCardsPerPage;
|
||||
return filteredRows.slice(start, start + expandedCardsPerPage);
|
||||
}, [filteredRows, isExpanded, visibleCardCount, currentPage, expandedCardsPerPage]);
|
||||
return source.slice(start, start + expandedCardsPerPage);
|
||||
}, [filteredRows, isExpanded, visibleCardCount, currentPage, expandedCardsPerPage, ownerSortColumn, currentUserId]);
|
||||
|
||||
const totalPages = isExpanded ? Math.ceil(filteredRows.length / expandedCardsPerPage) : 1;
|
||||
const needsPagination = isExpanded && totalPages > 1;
|
||||
|
|
@ -756,10 +802,17 @@ export function PopCardListV2Component({
|
|||
if (firstPending) { firstPending.isCurrent = true; }
|
||||
}
|
||||
|
||||
return fetchedRows.map((row) => ({
|
||||
...row,
|
||||
__processFlow__: processMap.get(String(row.id)) || [],
|
||||
}));
|
||||
return fetchedRows.map((row) => {
|
||||
const steps = processMap.get(String(row.id)) || [];
|
||||
const current = steps.find((s) => s.isCurrent);
|
||||
const processFields: Record<string, unknown> = {};
|
||||
if (current?.rawData) {
|
||||
for (const [key, val] of Object.entries(current.rawData)) {
|
||||
processFields[`__process_${key}`] = val;
|
||||
}
|
||||
}
|
||||
return { ...row, __processFlow__: steps, ...processFields };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
|
|
@ -1041,6 +1094,7 @@ export function PopCardListV2Component({
|
|||
onToggleRowSelect={() => toggleRowSelection(row)}
|
||||
onEnterSelectMode={enterSelectMode}
|
||||
onOpenPopModal={openPopModal}
|
||||
currentUserId={currentUserId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -1148,6 +1202,8 @@ interface CardV2Props {
|
|||
onToggleRowSelect?: () => void;
|
||||
onEnterSelectMode?: (whenStatus: string, buttonConfig: Record<string, unknown>) => void;
|
||||
onOpenPopModal?: (screenId: string, row: RowData) => void;
|
||||
currentUserId?: string;
|
||||
isLockedByOther?: boolean;
|
||||
}
|
||||
|
||||
function CardV2({
|
||||
|
|
@ -1155,7 +1211,7 @@ function CardV2({
|
|||
parentComponentId, isCartListMode, isSelected, onToggleSelect,
|
||||
onDeleteItem, onUpdateQuantity, onRefresh,
|
||||
selectMode, isSelectModeSelected, isSelectable, onToggleRowSelect, onEnterSelectMode,
|
||||
onOpenPopModal,
|
||||
onOpenPopModal, currentUserId, isLockedByOther,
|
||||
}: CardV2Props) {
|
||||
const inputField = config?.inputField;
|
||||
const cartAction = config?.cartAction;
|
||||
|
|
@ -1167,6 +1223,72 @@ function CardV2({
|
|||
const [packageEntries, setPackageEntries] = useState<PackageEntry[]>([]);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
const [qtyModalState, setQtyModalState] = useState<{
|
||||
open: boolean;
|
||||
row: RowData;
|
||||
processId?: string | number;
|
||||
action: ActionButtonClickAction;
|
||||
} | null>(null);
|
||||
|
||||
const handleQtyConfirm = useCallback(async (value: number) => {
|
||||
if (!qtyModalState) return;
|
||||
const { row: actionRow, processId: qtyProcessId, action } = qtyModalState;
|
||||
setQtyModalState(null);
|
||||
if (!action.targetTable || !action.updates) return;
|
||||
|
||||
const rowId = qtyProcessId ?? actionRow.id ?? actionRow.pk;
|
||||
if (!rowId) { toast.error("대상 레코드 ID를 찾을 수 없습니다."); return; }
|
||||
|
||||
const lookupValue = action.joinConfig
|
||||
? String(actionRow[action.joinConfig.sourceColumn] ?? rowId)
|
||||
: rowId;
|
||||
const lookupColumn = action.joinConfig?.targetColumn || "id";
|
||||
|
||||
const tasks = action.updates.map((u, idx) => ({
|
||||
id: `qty-update-${idx}`,
|
||||
type: "data-update" as const,
|
||||
targetTable: action.targetTable!,
|
||||
targetColumn: u.column,
|
||||
operationType: (u.operationType || "assign") as "assign" | "add" | "subtract",
|
||||
valueSource: "fixed" as const,
|
||||
fixedValue: u.valueType === "userInput" ? String(value) :
|
||||
u.valueType === "static" ? (u.value ?? "") :
|
||||
u.valueType === "currentUser" ? "__CURRENT_USER__" :
|
||||
u.valueType === "currentTime" ? "__CURRENT_TIME__" :
|
||||
u.valueType === "columnRef" ? String(actionRow[u.value ?? ""] ?? "") :
|
||||
(u.value ?? ""),
|
||||
lookupMode: "manual" as const,
|
||||
manualItemField: lookupColumn,
|
||||
manualPkColumn: lookupColumn,
|
||||
...(idx === 0 && action.preCondition ? { preCondition: action.preCondition } : {}),
|
||||
}));
|
||||
|
||||
const targetRow = action.joinConfig
|
||||
? { ...actionRow, [lookupColumn]: lookupValue }
|
||||
: qtyProcessId ? { ...actionRow, id: qtyProcessId } : actionRow;
|
||||
|
||||
try {
|
||||
const result = await apiClient.post("/pop/execute-action", {
|
||||
tasks,
|
||||
data: { items: [targetRow], fieldValues: {} },
|
||||
mappings: {},
|
||||
});
|
||||
if (result.data?.success) {
|
||||
toast.success(result.data.message || "처리 완료");
|
||||
onRefresh?.();
|
||||
} else {
|
||||
toast.error(result.data?.message || "처리 실패");
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if ((err as any)?.response?.status === 409) {
|
||||
toast.error((err as any).response?.data?.message || "이미 다른 사용자가 처리한 작업입니다.");
|
||||
onRefresh?.();
|
||||
} else {
|
||||
toast.error(err instanceof Error ? err.message : "처리 중 오류 발생");
|
||||
}
|
||||
}
|
||||
}, [qtyModalState, onRefresh]);
|
||||
|
||||
const rowKey = keyColumnName && row[keyColumnName] ? String(row[keyColumnName]) : "";
|
||||
const isCarted = cart.isItemInCart(rowKey);
|
||||
const existingCartItem = cart.getCartItem(rowKey);
|
||||
|
|
@ -1365,7 +1487,11 @@ function CardV2({
|
|||
}
|
||||
|
||||
for (const action of actionsToRun) {
|
||||
if (action.type === "immediate" && action.updates && action.updates.length > 0 && action.targetTable) {
|
||||
if (action.type === "quantity-input" && action.targetTable && action.updates) {
|
||||
if (action.confirmMessage && !window.confirm(action.confirmMessage)) return;
|
||||
setQtyModalState({ open: true, row: actionRow, processId, action });
|
||||
return;
|
||||
} else if (action.type === "immediate" && action.updates && action.updates.length > 0 && action.targetTable) {
|
||||
if (action.confirmMessage) {
|
||||
if (!window.confirm(action.confirmMessage)) return;
|
||||
}
|
||||
|
|
@ -1381,7 +1507,7 @@ function CardV2({
|
|||
type: "data-update" as const,
|
||||
targetTable: action.targetTable!,
|
||||
targetColumn: u.column,
|
||||
operationType: "assign" as const,
|
||||
operationType: (u.operationType || "assign") as "assign" | "add" | "subtract",
|
||||
valueSource: "fixed" as const,
|
||||
fixedValue: u.valueType === "static" ? (u.value ?? "") :
|
||||
u.valueType === "currentUser" ? "__CURRENT_USER__" :
|
||||
|
|
@ -1391,6 +1517,7 @@ function CardV2({
|
|||
lookupMode: "manual" as const,
|
||||
manualItemField: lookupColumn,
|
||||
manualPkColumn: lookupColumn,
|
||||
...(idx === 0 && action.preCondition ? { preCondition: action.preCondition } : {}),
|
||||
}));
|
||||
const targetRow = action.joinConfig
|
||||
? { ...actionRow, [lookupColumn]: lookupValue }
|
||||
|
|
@ -1408,7 +1535,12 @@ function CardV2({
|
|||
return;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : "처리 중 오류 발생");
|
||||
if ((err as any)?.response?.status === 409) {
|
||||
toast.error((err as any).response?.data?.message || "이미 다른 사용자가 처리한 작업입니다.");
|
||||
onRefresh?.();
|
||||
} else {
|
||||
toast.error(err instanceof Error ? err.message : "처리 중 오류 발생");
|
||||
}
|
||||
return;
|
||||
}
|
||||
} else if (action.type === "modal-open" && action.modalScreenId) {
|
||||
|
|
@ -1418,6 +1550,7 @@ function CardV2({
|
|||
},
|
||||
packageEntries,
|
||||
inputUnit: inputField?.unit,
|
||||
currentUserId,
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -1437,6 +1570,17 @@ function CardV2({
|
|||
/>
|
||||
)}
|
||||
|
||||
{qtyModalState?.open && (
|
||||
<NumberInputModal
|
||||
open={true}
|
||||
onOpenChange={(open) => { if (!open) setQtyModalState(null); }}
|
||||
unit={qtyModalState.action.quantityInput?.unit || "EA"}
|
||||
maxValue={calculateMaxQty(qtyModalState.row, qtyModalState.processId, qtyModalState.action.quantityInput)}
|
||||
showPackageUnit={qtyModalState.action.quantityInput?.enablePackage ?? false}
|
||||
onConfirm={(value) => handleQtyConfirm(value)}
|
||||
/>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -65,6 +65,33 @@ import {
|
|||
type ColumnInfo,
|
||||
} from "../pop-dashboard/utils/dataFetcher";
|
||||
|
||||
// ===== 컬럼 옵션 그룹 =====
|
||||
|
||||
interface ColumnOptionGroup {
|
||||
groupLabel: string;
|
||||
options: { value: string; label: string }[];
|
||||
}
|
||||
|
||||
function renderColumnOptionGroups(groups: ColumnOptionGroup[]) {
|
||||
if (groups.length <= 1) {
|
||||
return groups.flatMap((g) =>
|
||||
g.options.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value} className="text-[10px]">{o.label}</SelectItem>
|
||||
))
|
||||
);
|
||||
}
|
||||
return groups
|
||||
.filter((g) => g.options.length > 0)
|
||||
.map((g) => (
|
||||
<SelectGroup key={g.groupLabel}>
|
||||
<SelectLabel className="text-[9px] font-semibold text-muted-foreground px-2 py-1">{g.groupLabel}</SelectLabel>
|
||||
{g.options.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value} className="text-[10px]">{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
));
|
||||
}
|
||||
|
||||
// ===== Props =====
|
||||
|
||||
interface ConfigPanelProps {
|
||||
|
|
@ -271,6 +298,7 @@ export function PopCardListV2ConfigPanel({ config, onUpdate }: ConfigPanelProps)
|
|||
<TabActions
|
||||
cfg={cfg}
|
||||
onUpdate={update}
|
||||
columns={columns}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -759,10 +787,36 @@ function TabCardDesign({
|
|||
sourceTable: j.targetTable,
|
||||
}))
|
||||
);
|
||||
const allColumnOptions = [
|
||||
...availableColumns.map((c) => ({ value: c.name, label: c.name })),
|
||||
...joinedColumns.map((c) => ({ value: c.name, label: `${c.displayName} (${c.sourceTable})` })),
|
||||
|
||||
const [processColumns, setProcessColumns] = useState<ColumnInfo[]>([]);
|
||||
const timelineCell = cfg.cardGrid.cells.find((c) => c.type === "timeline" && c.timelineSource?.processTable);
|
||||
const processTableName = timelineCell?.timelineSource?.processTable || "";
|
||||
useEffect(() => {
|
||||
if (!processTableName) { setProcessColumns([]); return; }
|
||||
fetchTableColumns(processTableName)
|
||||
.then(setProcessColumns)
|
||||
.catch(() => setProcessColumns([]));
|
||||
}, [processTableName]);
|
||||
|
||||
const columnOptionGroups: ColumnOptionGroup[] = [
|
||||
{
|
||||
groupLabel: `메인 (${cfg.dataSource.tableName || "테이블"})`,
|
||||
options: availableColumns.map((c) => ({ value: c.name, label: c.name })),
|
||||
},
|
||||
...(joinedColumns.length > 0
|
||||
? [{
|
||||
groupLabel: "조인",
|
||||
options: joinedColumns.map((c) => ({ value: c.name, label: `${c.displayName} (${c.sourceTable})` })),
|
||||
}]
|
||||
: []),
|
||||
...(processColumns.length > 0
|
||||
? [{
|
||||
groupLabel: `공정 (${processTableName})`,
|
||||
options: processColumns.map((c) => ({ value: `__process_${c.name}`, label: c.name })),
|
||||
}]
|
||||
: []),
|
||||
];
|
||||
const allColumnOptions = columnOptionGroups.flatMap((g) => g.options);
|
||||
|
||||
const [selectedCellId, setSelectedCellId] = useState<string | null>(null);
|
||||
const [mergeMode, setMergeMode] = useState(false);
|
||||
|
|
@ -1273,6 +1327,7 @@ function TabCardDesign({
|
|||
cell={selectedCell}
|
||||
allCells={grid.cells}
|
||||
allColumnOptions={allColumnOptions}
|
||||
columnOptionGroups={columnOptionGroups}
|
||||
columns={columns}
|
||||
selectedColumns={selectedColumns}
|
||||
tables={tables}
|
||||
|
|
@ -1291,6 +1346,7 @@ function CellDetailEditor({
|
|||
cell,
|
||||
allCells,
|
||||
allColumnOptions,
|
||||
columnOptionGroups,
|
||||
columns,
|
||||
selectedColumns,
|
||||
tables,
|
||||
|
|
@ -1301,6 +1357,7 @@ function CellDetailEditor({
|
|||
cell: CardCellDefinitionV2;
|
||||
allCells: CardCellDefinitionV2[];
|
||||
allColumnOptions: { value: string; label: string }[];
|
||||
columnOptionGroups: ColumnOptionGroup[];
|
||||
columns: ColumnInfo[];
|
||||
selectedColumns: string[];
|
||||
tables: TableInfo[];
|
||||
|
|
@ -1348,9 +1405,7 @@ function CellDetailEditor({
|
|||
<SelectTrigger className="h-7 flex-1 text-[10px]"><SelectValue placeholder="컬럼" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none" className="text-[10px]">미지정</SelectItem>
|
||||
{allColumnOptions.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value} className="text-[10px]">{o.label}</SelectItem>
|
||||
))}
|
||||
{renderColumnOptionGroups(columnOptionGroups)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
|
@ -1417,9 +1472,9 @@ function CellDetailEditor({
|
|||
{/* 타입별 상세 설정 */}
|
||||
{cell.type === "status-badge" && <StatusMappingEditor cell={cell} allCells={allCells} onUpdate={onUpdate} />}
|
||||
{cell.type === "timeline" && <TimelineConfigEditor cell={cell} allColumnOptions={allColumnOptions} tables={tables} onUpdate={onUpdate} />}
|
||||
{cell.type === "action-buttons" && <ActionButtonsEditor cell={cell} allCells={allCells} allColumnOptions={allColumnOptions} availableTableOptions={availableTableOptions} onUpdate={onUpdate} />}
|
||||
{cell.type === "footer-status" && <FooterStatusEditor cell={cell} allColumnOptions={allColumnOptions} onUpdate={onUpdate} />}
|
||||
{cell.type === "field" && <FieldConfigEditor cell={cell} allColumnOptions={allColumnOptions} onUpdate={onUpdate} />}
|
||||
{cell.type === "action-buttons" && <ActionButtonsEditor cell={cell} allCells={allCells} allColumnOptions={allColumnOptions} columnOptionGroups={columnOptionGroups} availableTableOptions={availableTableOptions} onUpdate={onUpdate} />}
|
||||
{cell.type === "footer-status" && <FooterStatusEditor cell={cell} allColumnOptions={allColumnOptions} columnOptionGroups={columnOptionGroups} onUpdate={onUpdate} />}
|
||||
{cell.type === "field" && <FieldConfigEditor cell={cell} allColumnOptions={allColumnOptions} columnOptionGroups={columnOptionGroups} onUpdate={onUpdate} />}
|
||||
{cell.type === "number-input" && (
|
||||
<div className="space-y-1">
|
||||
<span className="text-[9px] font-medium text-muted-foreground">숫자 입력 설정</span>
|
||||
|
|
@ -1429,7 +1484,7 @@ function CellDetailEditor({
|
|||
<SelectTrigger className="h-7 flex-1 text-[10px]"><SelectValue placeholder="제한 컬럼" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__" className="text-[10px]">없음</SelectItem>
|
||||
{allColumnOptions.map((o) => <SelectItem key={o.value} value={o.value} className="text-[10px]">{o.label}</SelectItem>)}
|
||||
{renderColumnOptionGroups(columnOptionGroups)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
|
@ -1809,12 +1864,14 @@ function ActionButtonsEditor({
|
|||
cell,
|
||||
allCells,
|
||||
allColumnOptions,
|
||||
columnOptionGroups,
|
||||
availableTableOptions,
|
||||
onUpdate,
|
||||
}: {
|
||||
cell: CardCellDefinitionV2;
|
||||
allCells: CardCellDefinitionV2[];
|
||||
allColumnOptions: { value: string; label: string }[];
|
||||
columnOptionGroups: ColumnOptionGroup[];
|
||||
availableTableOptions: { value: string; label: string }[];
|
||||
onUpdate: (partial: Partial<CardCellDefinitionV2>) => void;
|
||||
}) {
|
||||
|
|
@ -1975,7 +2032,7 @@ function ActionButtonsEditor({
|
|||
|
||||
const isSectionOpen = (key: string) => expandedSections[key] !== false;
|
||||
|
||||
const ACTION_TYPE_LABELS: Record<string, string> = { immediate: "즉시 실행", "select-mode": "선택 후 실행", "modal-open": "모달 열기" };
|
||||
const ACTION_TYPE_LABELS: Record<string, string> = { immediate: "즉시 실행", "select-mode": "선택 후 실행", "modal-open": "모달 열기", "quantity-input": "수량 입력" };
|
||||
|
||||
const getCondSummary = (btn: ActionButtonDef) => {
|
||||
const c = btn.showCondition;
|
||||
|
|
@ -1985,6 +2042,7 @@ function ActionButtonsEditor({
|
|||
return opt ? opt.label : (c.value || "미설정");
|
||||
}
|
||||
if (c.type === "column-value") return `${c.column || "?"} = ${c.value || "?"}`;
|
||||
if (c.type === "owner-match") return `소유자(${c.column || "?"})`;
|
||||
return "항상";
|
||||
};
|
||||
|
||||
|
|
@ -2081,8 +2139,21 @@ function ActionButtonsEditor({
|
|||
<SelectItem value="always" className="text-[10px]">항상</SelectItem>
|
||||
<SelectItem value="timeline-status" className="text-[10px]">타임라인</SelectItem>
|
||||
<SelectItem value="column-value" className="text-[10px]">카드 컬럼</SelectItem>
|
||||
<SelectItem value="owner-match" className="text-[10px]">소유자 일치</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{condType === "owner-match" && (
|
||||
<Select
|
||||
value={btn.showCondition?.column || "__none__"}
|
||||
onValueChange={(v) => updateCondition(bi, { column: v === "__none__" ? "" : v })}
|
||||
>
|
||||
<SelectTrigger className="h-6 flex-1 text-[10px]"><SelectValue placeholder="컬럼" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__" className="text-[10px]">선택</SelectItem>
|
||||
{renderColumnOptionGroups(columnOptionGroups)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{condType === "timeline-status" && (
|
||||
<Select
|
||||
value={btn.showCondition?.value || "__none__"}
|
||||
|
|
@ -2106,9 +2177,7 @@ function ActionButtonsEditor({
|
|||
<SelectTrigger className="h-6 w-24 text-[10px]"><SelectValue placeholder="컬럼" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__" className="text-[10px]">선택</SelectItem>
|
||||
{allColumnOptions.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value} className="text-[10px]">{o.label}</SelectItem>
|
||||
))}
|
||||
{renderColumnOptionGroups(columnOptionGroups)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
|
|
@ -2168,6 +2237,7 @@ function ActionButtonsEditor({
|
|||
<SelectTrigger className="h-6 flex-1 text-[10px]"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="immediate" className="text-[10px]">즉시 실행</SelectItem>
|
||||
<SelectItem value="quantity-input" className="text-[10px]">수량 입력</SelectItem>
|
||||
<SelectItem value="select-mode" className="text-[10px]">선택 후 실행</SelectItem>
|
||||
<SelectItem value="modal-open" className="text-[10px]">모달 열기</SelectItem>
|
||||
</SelectContent>
|
||||
|
|
@ -2191,6 +2261,50 @@ function ActionButtonsEditor({
|
|||
/>
|
||||
)}
|
||||
|
||||
{aType === "quantity-input" && (
|
||||
<div className="space-y-1.5">
|
||||
<ImmediateActionEditor
|
||||
action={action}
|
||||
allColumnOptions={allColumnOptions}
|
||||
availableTableOptions={availableTableOptions}
|
||||
onAddUpdate={() => addActionUpdate(bi, ai)}
|
||||
onUpdateUpdate={(ui, p) => updateActionUpdate(bi, ai, ui, p)}
|
||||
onRemoveUpdate={(ui) => removeActionUpdate(bi, ai, ui)}
|
||||
onUpdateAction={(p) => updateAction(bi, ai, p)}
|
||||
/>
|
||||
<div className="rounded border bg-background/50 p-1.5 space-y-1">
|
||||
<span className="text-[8px] font-medium text-muted-foreground">수량 모달 설정</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="w-14 shrink-0 text-[8px] text-muted-foreground">최대값 컬럼</span>
|
||||
<Input
|
||||
value={action.quantityInput?.maxColumn || ""}
|
||||
onChange={(e) => updateAction(bi, ai, { quantityInput: { ...action.quantityInput, maxColumn: e.target.value } })}
|
||||
placeholder="예: qty"
|
||||
className="h-6 flex-1 text-[10px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="w-14 shrink-0 text-[8px] text-muted-foreground">현재값 컬럼</span>
|
||||
<Input
|
||||
value={action.quantityInput?.currentColumn || ""}
|
||||
onChange={(e) => updateAction(bi, ai, { quantityInput: { ...action.quantityInput, currentColumn: e.target.value } })}
|
||||
placeholder="예: input_qty"
|
||||
className="h-6 flex-1 text-[10px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="w-14 shrink-0 text-[8px] text-muted-foreground">단위</span>
|
||||
<Input
|
||||
value={action.quantityInput?.unit || ""}
|
||||
onChange={(e) => updateAction(bi, ai, { quantityInput: { ...action.quantityInput, unit: e.target.value } })}
|
||||
placeholder="예: EA"
|
||||
className="h-6 w-20 text-[10px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{aType === "select-mode" && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
|
|
@ -2455,6 +2569,70 @@ function ImmediateActionEditor({
|
|||
className="h-6 flex-1 text-[10px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 사전 조건 (중복 방지) */}
|
||||
<div className="rounded border bg-background/50 p-1.5 space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[8px] font-medium text-muted-foreground">사전 조건 (중복 방지)</span>
|
||||
<Switch
|
||||
checked={!!action.preCondition}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
onUpdateAction({ preCondition: { column: "", expectedValue: "", failMessage: "" } });
|
||||
} else {
|
||||
onUpdateAction({ preCondition: undefined });
|
||||
}
|
||||
}}
|
||||
className="h-3.5 w-7 [&>span]:h-2.5 [&>span]:w-2.5"
|
||||
/>
|
||||
</div>
|
||||
{action.preCondition && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="w-14 shrink-0 text-[8px] text-muted-foreground">검증 컬럼</span>
|
||||
<Select
|
||||
value={action.preCondition.column || "__none__"}
|
||||
onValueChange={(v) => onUpdateAction({ preCondition: { ...action.preCondition!, column: v === "__none__" ? "" : v } })}
|
||||
>
|
||||
<SelectTrigger className="h-6 flex-1 text-[10px]"><SelectValue placeholder="컬럼" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__" className="text-[10px]">선택</SelectItem>
|
||||
{businessCols.length > 0 && (
|
||||
<SelectGroup>
|
||||
<SelectLabel className="text-[8px] text-muted-foreground">{tableName}</SelectLabel>
|
||||
{businessCols.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value} className="text-[10px]">{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="w-14 shrink-0 text-[8px] text-muted-foreground">기대값</span>
|
||||
<Input
|
||||
value={action.preCondition.expectedValue || ""}
|
||||
onChange={(e) => onUpdateAction({ preCondition: { ...action.preCondition!, expectedValue: e.target.value } })}
|
||||
placeholder="예: waiting"
|
||||
className="h-6 flex-1 text-[10px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="w-14 shrink-0 text-[8px] text-muted-foreground">실패 메시지</span>
|
||||
<Input
|
||||
value={action.preCondition.failMessage || ""}
|
||||
onChange={(e) => onUpdateAction({ preCondition: { ...action.preCondition!, failMessage: e.target.value } })}
|
||||
placeholder="이미 다른 사용자가 처리했습니다"
|
||||
className="h-6 flex-1 text-[10px]"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[7px] text-muted-foreground/70 pl-0.5">
|
||||
실행 시 해당 컬럼의 현재 DB 값이 기대값과 일치할 때만 처리됩니다
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[8px] font-medium text-muted-foreground">
|
||||
변경할 컬럼{tableName ? ` (${tableName})` : ""}
|
||||
|
|
@ -2491,11 +2669,22 @@ function ImmediateActionEditor({
|
|||
<SelectTrigger className="h-6 w-20 text-[10px]"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="static" className="text-[10px]">직접입력</SelectItem>
|
||||
<SelectItem value="userInput" className="text-[10px]">사용자 입력</SelectItem>
|
||||
<SelectItem value="currentUser" className="text-[10px]">현재 사용자</SelectItem>
|
||||
<SelectItem value="currentTime" className="text-[10px]">현재 시간</SelectItem>
|
||||
<SelectItem value="columnRef" className="text-[10px]">컬럼 참조</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{u.valueType === "userInput" && (
|
||||
<Select value={u.operationType || "assign"} onValueChange={(v) => onUpdateUpdate(ui, { operationType: v as ActionButtonUpdate["operationType"] })}>
|
||||
<SelectTrigger className="h-6 w-16 text-[10px]"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="assign" className="text-[10px]">대입</SelectItem>
|
||||
<SelectItem value="add" className="text-[10px]">합산</SelectItem>
|
||||
<SelectItem value="subtract" className="text-[10px]">차감</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{(u.valueType === "static" || u.valueType === "columnRef") && (
|
||||
<Input
|
||||
value={u.value || ""}
|
||||
|
|
@ -2608,10 +2797,12 @@ function DbTableCombobox({
|
|||
function FooterStatusEditor({
|
||||
cell,
|
||||
allColumnOptions,
|
||||
columnOptionGroups,
|
||||
onUpdate,
|
||||
}: {
|
||||
cell: CardCellDefinitionV2;
|
||||
allColumnOptions: { value: string; label: string }[];
|
||||
columnOptionGroups: ColumnOptionGroup[];
|
||||
onUpdate: (partial: Partial<CardCellDefinitionV2>) => void;
|
||||
}) {
|
||||
const footerStatusMap = cell.footerStatusMap || [];
|
||||
|
|
@ -2644,7 +2835,7 @@ function FooterStatusEditor({
|
|||
<SelectTrigger className="h-7 flex-1 text-[10px]"><SelectValue placeholder="상태 컬럼" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__" className="text-[10px]">없음</SelectItem>
|
||||
{allColumnOptions.map((o) => <SelectItem key={o.value} value={o.value} className="text-[10px]">{o.label}</SelectItem>)}
|
||||
{renderColumnOptionGroups(columnOptionGroups)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
|
@ -2680,10 +2871,12 @@ function FooterStatusEditor({
|
|||
function FieldConfigEditor({
|
||||
cell,
|
||||
allColumnOptions,
|
||||
columnOptionGroups,
|
||||
onUpdate,
|
||||
}: {
|
||||
cell: CardCellDefinitionV2;
|
||||
allColumnOptions: { value: string; label: string }[];
|
||||
columnOptionGroups: ColumnOptionGroup[];
|
||||
onUpdate: (partial: Partial<CardCellDefinitionV2>) => void;
|
||||
}) {
|
||||
const valueType = cell.valueType || "column";
|
||||
|
|
@ -2706,7 +2899,7 @@ function FieldConfigEditor({
|
|||
<Select value={cell.formulaLeft || ""} onValueChange={(v) => onUpdate({ formulaLeft: v })}>
|
||||
<SelectTrigger className="h-7 flex-1 text-[10px]"><SelectValue placeholder="좌항" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{allColumnOptions.map((o) => <SelectItem key={o.value} value={o.value} className="text-[10px]">{o.label}</SelectItem>)}
|
||||
{renderColumnOptionGroups(columnOptionGroups)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={cell.formulaOperator || "+"} onValueChange={(v) => onUpdate({ formulaOperator: v as "+" | "-" | "*" | "/" })}>
|
||||
|
|
@ -2726,7 +2919,7 @@ function FieldConfigEditor({
|
|||
<Select value={cell.formulaRight || ""} onValueChange={(v) => onUpdate({ formulaRight: v })}>
|
||||
<SelectTrigger className="h-7 flex-1 text-[10px]"><SelectValue placeholder="우항" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{allColumnOptions.map((o) => <SelectItem key={o.value} value={o.value} className="text-[10px]">{o.label}</SelectItem>)}
|
||||
{renderColumnOptionGroups(columnOptionGroups)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
|
@ -2741,16 +2934,61 @@ function FieldConfigEditor({
|
|||
function TabActions({
|
||||
cfg,
|
||||
onUpdate,
|
||||
columns,
|
||||
}: {
|
||||
cfg: PopCardListV2Config;
|
||||
onUpdate: (partial: Partial<PopCardListV2Config>) => void;
|
||||
columns: ColumnInfo[];
|
||||
}) {
|
||||
const overflow = cfg.overflow || { mode: "loadMore" as const, visibleCount: 6 };
|
||||
const clickAction = cfg.cardClickAction || "none";
|
||||
const modalConfig = cfg.cardClickModalConfig || { screenId: "" };
|
||||
|
||||
const [processColumns, setProcessColumns] = useState<ColumnInfo[]>([]);
|
||||
const timelineCell = cfg.cardGrid?.cells?.find((c) => c.type === "timeline" && c.timelineSource?.processTable);
|
||||
const processTableName = timelineCell?.timelineSource?.processTable || "";
|
||||
useEffect(() => {
|
||||
if (!processTableName) { setProcessColumns([]); return; }
|
||||
fetchTableColumns(processTableName)
|
||||
.then(setProcessColumns)
|
||||
.catch(() => setProcessColumns([]));
|
||||
}, [processTableName]);
|
||||
|
||||
const ownerColumnGroups: ColumnOptionGroup[] = useMemo(() => [
|
||||
{
|
||||
groupLabel: `메인 (${cfg.dataSource?.tableName || "테이블"})`,
|
||||
options: columns.map((c) => ({ value: c.name, label: c.name })),
|
||||
},
|
||||
...(processColumns.length > 0
|
||||
? [{
|
||||
groupLabel: `공정 (${processTableName})`,
|
||||
options: processColumns.map((c) => ({ value: `__process_${c.name}`, label: c.name })),
|
||||
}]
|
||||
: []),
|
||||
], [columns, processColumns, processTableName, cfg.dataSource?.tableName]);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* 소유자 우선 정렬 */}
|
||||
<div>
|
||||
<Label className="text-xs">소유자 우선 정렬</Label>
|
||||
<div className="mt-1 flex items-center gap-1">
|
||||
<Select
|
||||
value={cfg.ownerSortColumn || "__none__"}
|
||||
onValueChange={(v) => onUpdate({ ownerSortColumn: v === "__none__" ? undefined : v })}
|
||||
>
|
||||
<SelectTrigger className="h-7 flex-1 text-[10px]"><SelectValue placeholder="사용 안 함" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__" className="text-[10px]">사용 안 함</SelectItem>
|
||||
{renderColumnOptionGroups(ownerColumnGroups)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<p className="mt-0.5 text-[9px] text-muted-foreground">
|
||||
선택한 컬럼 값이 현재 로그인 사용자와 일치하는 카드가 맨 위에 표시됩니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 카드 선택 시 */}
|
||||
<div>
|
||||
<Label className="text-xs">카드 선택 시 동작</Label>
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@ export interface CellRendererProps {
|
|||
onEnterSelectMode?: (whenStatus: string, buttonConfig: Record<string, unknown>) => void;
|
||||
packageEntries?: PackageEntry[];
|
||||
inputUnit?: string;
|
||||
currentUserId?: string;
|
||||
}
|
||||
|
||||
// ===== 메인 디스패치 =====
|
||||
|
|
@ -592,7 +593,7 @@ function TimelineCell({ cell, row }: CellRendererProps) {
|
|||
|
||||
// ===== 11. action-buttons =====
|
||||
|
||||
function evaluateShowCondition(btn: ActionButtonDef, row: RowData): "visible" | "disabled" | "hidden" {
|
||||
function evaluateShowCondition(btn: ActionButtonDef, row: RowData, currentUserId?: string): "visible" | "disabled" | "hidden" {
|
||||
const cond = btn.showCondition;
|
||||
if (!cond || cond.type === "always") return "visible";
|
||||
|
||||
|
|
@ -603,6 +604,9 @@ function evaluateShowCondition(btn: ActionButtonDef, row: RowData): "visible" |
|
|||
matched = subStatus !== undefined && String(subStatus) === cond.value;
|
||||
} else if (cond.type === "column-value" && cond.column) {
|
||||
matched = String(row[cond.column] ?? "") === (cond.value ?? "");
|
||||
} else if (cond.type === "owner-match" && cond.column) {
|
||||
const ownerValue = String(row[cond.column] ?? "");
|
||||
matched = !!currentUserId && ownerValue === currentUserId;
|
||||
} else {
|
||||
return "visible";
|
||||
}
|
||||
|
|
@ -611,7 +615,7 @@ function evaluateShowCondition(btn: ActionButtonDef, row: RowData): "visible" |
|
|||
return cond.unmatchBehavior === "disabled" ? "disabled" : "hidden";
|
||||
}
|
||||
|
||||
function ActionButtonsCell({ cell, row, onActionButtonClick, onEnterSelectMode }: CellRendererProps) {
|
||||
function ActionButtonsCell({ cell, row, onActionButtonClick, onEnterSelectMode, currentUserId }: CellRendererProps) {
|
||||
const processFlow = row.__processFlow__ as { isCurrent: boolean; processId?: string | number }[] | undefined;
|
||||
const currentProcess = processFlow?.find((s) => s.isCurrent);
|
||||
const currentProcessId = currentProcess?.processId;
|
||||
|
|
@ -619,7 +623,7 @@ function ActionButtonsCell({ cell, row, onActionButtonClick, onEnterSelectMode }
|
|||
if (cell.actionButtons && cell.actionButtons.length > 0) {
|
||||
const evaluated = cell.actionButtons.map((btn) => ({
|
||||
btn,
|
||||
state: evaluateShowCondition(btn, row),
|
||||
state: evaluateShowCondition(btn, row, currentUserId),
|
||||
}));
|
||||
|
||||
const activeBtn = evaluated.find((e) => e.state === "visible");
|
||||
|
|
|
|||
|
|
@ -851,7 +851,8 @@ export interface CardCellDefinitionV2 {
|
|||
export interface ActionButtonUpdate {
|
||||
column: string;
|
||||
value?: string;
|
||||
valueType: "static" | "currentUser" | "currentTime" | "columnRef";
|
||||
valueType: "static" | "currentUser" | "currentTime" | "columnRef" | "userInput";
|
||||
operationType?: "assign" | "add" | "subtract";
|
||||
}
|
||||
|
||||
// 액션 버튼 클릭 시 동작 모드
|
||||
|
|
@ -881,34 +882,49 @@ export interface SelectModeConfig {
|
|||
export interface SelectModeButtonConfig {
|
||||
label: string;
|
||||
variant: ButtonVariant;
|
||||
clickMode: "status-change" | "modal-open" | "cancel-select";
|
||||
clickMode: "status-change" | "modal-open" | "cancel-select" | "quantity-input";
|
||||
targetTable?: string;
|
||||
updates?: ActionButtonUpdate[];
|
||||
confirmMessage?: string;
|
||||
modalScreenId?: string;
|
||||
quantityInput?: QuantityInputConfig;
|
||||
}
|
||||
|
||||
// ===== 버튼 중심 구조 (신규) =====
|
||||
|
||||
export interface ActionButtonShowCondition {
|
||||
type: "timeline-status" | "column-value" | "always";
|
||||
type: "timeline-status" | "column-value" | "always" | "owner-match";
|
||||
value?: string;
|
||||
column?: string;
|
||||
unmatchBehavior?: "hidden" | "disabled";
|
||||
}
|
||||
|
||||
export interface ActionButtonClickAction {
|
||||
type: "immediate" | "select-mode" | "modal-open";
|
||||
type: "immediate" | "select-mode" | "modal-open" | "quantity-input";
|
||||
targetTable?: string;
|
||||
updates?: ActionButtonUpdate[];
|
||||
confirmMessage?: string;
|
||||
selectModeButtons?: SelectModeButtonConfig[];
|
||||
modalScreenId?: string;
|
||||
// 외부 테이블 조인 설정 (DB 직접 선택 시)
|
||||
joinConfig?: {
|
||||
sourceColumn: string; // 메인 테이블의 FK 컬럼
|
||||
targetColumn: string; // 외부 테이블의 매칭 컬럼
|
||||
sourceColumn: string;
|
||||
targetColumn: string;
|
||||
};
|
||||
quantityInput?: QuantityInputConfig;
|
||||
preCondition?: ActionPreCondition;
|
||||
}
|
||||
|
||||
export interface QuantityInputConfig {
|
||||
maxColumn?: string;
|
||||
currentColumn?: string;
|
||||
unit?: string;
|
||||
enablePackage?: boolean;
|
||||
}
|
||||
|
||||
export interface ActionPreCondition {
|
||||
column: string;
|
||||
expectedValue: string;
|
||||
failMessage?: string;
|
||||
}
|
||||
|
||||
export interface ActionButtonDef {
|
||||
|
|
@ -976,6 +992,7 @@ export interface PopCardListV2Config {
|
|||
cartAction?: CardCartActionConfig;
|
||||
cartListMode?: CartListModeConfig;
|
||||
saveMapping?: CardListSaveMapping;
|
||||
ownerSortColumn?: string;
|
||||
}
|
||||
|
||||
/** 카드 컴포넌트가 하위 필터 적용 시 주입하는 가상 컬럼 키 */
|
||||
|
|
|
|||
Loading…
Reference in New Issue