Merge origin/main into ksh - resolve split-panel-layout2 conflicts
This commit is contained in:
commit
5c12b9fa83
|
|
@ -1,14 +1,15 @@
|
|||
// 공차중계 운전자 컨트롤러
|
||||
import { Request, Response } from "express";
|
||||
import { Response } from "express";
|
||||
import { query } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
|
||||
export class DriverController {
|
||||
/**
|
||||
* GET /api/driver/profile
|
||||
* 운전자 프로필 조회
|
||||
*/
|
||||
static async getProfile(req: Request, res: Response): Promise<void> {
|
||||
static async getProfile(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = req.user?.userId;
|
||||
|
||||
|
|
@ -85,7 +86,7 @@ export class DriverController {
|
|||
* PUT /api/driver/profile
|
||||
* 운전자 프로필 수정 (이름, 연락처, 면허정보, 차량번호, 차종)
|
||||
*/
|
||||
static async updateProfile(req: Request, res: Response): Promise<void> {
|
||||
static async updateProfile(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = req.user?.userId;
|
||||
|
||||
|
|
@ -183,7 +184,7 @@ export class DriverController {
|
|||
* PUT /api/driver/status
|
||||
* 차량 상태 변경 (대기/정비만 가능)
|
||||
*/
|
||||
static async updateStatus(req: Request, res: Response): Promise<void> {
|
||||
static async updateStatus(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = req.user?.userId;
|
||||
|
||||
|
|
@ -246,7 +247,7 @@ export class DriverController {
|
|||
* DELETE /api/driver/vehicle
|
||||
* 차량 삭제 (user_id = NULL 처리, 기록 보존)
|
||||
*/
|
||||
static async deleteVehicle(req: Request, res: Response): Promise<void> {
|
||||
static async deleteVehicle(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = req.user?.userId;
|
||||
|
||||
|
|
@ -303,7 +304,7 @@ export class DriverController {
|
|||
* POST /api/driver/vehicle
|
||||
* 새 차량 등록
|
||||
*/
|
||||
static async registerVehicle(req: Request, res: Response): Promise<void> {
|
||||
static async registerVehicle(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = req.user?.userId;
|
||||
const companyCode = req.user?.companyCode;
|
||||
|
|
@ -400,7 +401,7 @@ export class DriverController {
|
|||
* DELETE /api/driver/account
|
||||
* 회원 탈퇴 (차량 정보 포함 삭제)
|
||||
*/
|
||||
static async deleteAccount(req: Request, res: Response): Promise<void> {
|
||||
static async deleteAccount(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = req.user?.userId;
|
||||
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ export class EntityJoinController {
|
|||
screenEntityConfigs, // 화면별 엔티티 설정 (JSON 문자열)
|
||||
autoFilter, // 🔒 멀티테넌시 자동 필터
|
||||
dataFilter, // 🆕 데이터 필터 (JSON 문자열)
|
||||
excludeFilter, // 🆕 제외 필터 (JSON 문자열) - 다른 테이블에 이미 존재하는 데이터 제외
|
||||
userLang, // userLang은 별도로 분리하여 search에 포함되지 않도록 함
|
||||
...otherParams
|
||||
} = req.query;
|
||||
|
|
@ -125,6 +126,19 @@ export class EntityJoinController {
|
|||
}
|
||||
}
|
||||
|
||||
// 🆕 제외 필터 처리 (다른 테이블에 이미 존재하는 데이터 제외)
|
||||
let parsedExcludeFilter: any = undefined;
|
||||
if (excludeFilter) {
|
||||
try {
|
||||
parsedExcludeFilter =
|
||||
typeof excludeFilter === "string" ? JSON.parse(excludeFilter) : excludeFilter;
|
||||
logger.info("제외 필터 파싱 완료:", parsedExcludeFilter);
|
||||
} catch (error) {
|
||||
logger.warn("제외 필터 파싱 오류:", error);
|
||||
parsedExcludeFilter = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const result = await tableManagementService.getTableDataWithEntityJoins(
|
||||
tableName,
|
||||
{
|
||||
|
|
@ -141,6 +155,7 @@ export class EntityJoinController {
|
|||
additionalJoinColumns: parsedAdditionalJoinColumns,
|
||||
screenEntityConfigs: parsedScreenEntityConfigs,
|
||||
dataFilter: parsedDataFilter, // 🆕 데이터 필터 전달
|
||||
excludeFilter: parsedExcludeFilter, // 🆕 제외 필터 전달
|
||||
}
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -403,18 +403,25 @@ export class EntityJoinService {
|
|||
const fromClause = `FROM ${tableName} main`;
|
||||
|
||||
// LEFT JOIN 절들 (위에서 생성한 별칭 매핑 사용, 각 sourceColumn마다 별도 JOIN)
|
||||
// 멀티테넌시: 모든 조인에 company_code 조건 추가 (다른 회사 데이터 혼합 방지)
|
||||
const joinClauses = uniqueReferenceTableConfigs
|
||||
.map((config) => {
|
||||
const aliasKey = `${config.referenceTable}:${config.sourceColumn}`;
|
||||
const alias = aliasMap.get(aliasKey);
|
||||
|
||||
// table_column_category_values는 특별한 조인 조건 필요 (회사별 필터링만)
|
||||
// table_column_category_values는 특별한 조인 조건 필요 (회사별 필터링)
|
||||
if (config.referenceTable === "table_column_category_values") {
|
||||
// 멀티테넌시: 회사 데이터만 사용 (공통 데이터 제외)
|
||||
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn} AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code AND ${alias}.is_active = true`;
|
||||
}
|
||||
|
||||
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn}`;
|
||||
// user_info는 전역 테이블이므로 company_code 조건 없이 조인
|
||||
if (config.referenceTable === "user_info") {
|
||||
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn}`;
|
||||
}
|
||||
|
||||
// 일반 테이블: company_code가 있으면 같은 회사 데이터만 조인 (멀티테넌시)
|
||||
// supplier_mng, customer_mng, item_info 등 회사별 데이터 테이블
|
||||
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn} AND ${alias}.company_code = main.company_code`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
|
|
|
|||
|
|
@ -2462,6 +2462,14 @@ export class TableManagementService {
|
|||
}>;
|
||||
screenEntityConfigs?: Record<string, any>; // 화면별 엔티티 설정
|
||||
dataFilter?: any; // 🆕 데이터 필터
|
||||
excludeFilter?: {
|
||||
enabled: boolean;
|
||||
referenceTable: string;
|
||||
referenceColumn: string;
|
||||
sourceColumn: string;
|
||||
filterColumn?: string;
|
||||
filterValue?: any;
|
||||
}; // 🆕 제외 필터 (다른 테이블에 이미 존재하는 데이터 제외)
|
||||
}
|
||||
): Promise<EntityJoinResponse> {
|
||||
const startTime = Date.now();
|
||||
|
|
@ -2716,6 +2724,44 @@ export class TableManagementService {
|
|||
}
|
||||
}
|
||||
|
||||
// 🆕 제외 필터 적용 (다른 테이블에 이미 존재하는 데이터 제외)
|
||||
if (options.excludeFilter && options.excludeFilter.enabled) {
|
||||
const {
|
||||
referenceTable,
|
||||
referenceColumn,
|
||||
sourceColumn,
|
||||
filterColumn,
|
||||
filterValue,
|
||||
} = options.excludeFilter;
|
||||
|
||||
if (referenceTable && referenceColumn && sourceColumn) {
|
||||
// 서브쿼리로 이미 존재하는 데이터 제외
|
||||
let excludeSubquery = `main."${sourceColumn}" NOT IN (
|
||||
SELECT "${referenceColumn}" FROM "${referenceTable}"
|
||||
WHERE "${referenceColumn}" IS NOT NULL`;
|
||||
|
||||
// 추가 필터 조건이 있으면 적용 (예: 특정 거래처의 품목만 제외)
|
||||
if (filterColumn && filterValue !== undefined && filterValue !== null) {
|
||||
excludeSubquery += ` AND "${filterColumn}" = '${String(filterValue).replace(/'/g, "''")}'`;
|
||||
}
|
||||
|
||||
excludeSubquery += ")";
|
||||
|
||||
whereClause = whereClause
|
||||
? `${whereClause} AND ${excludeSubquery}`
|
||||
: excludeSubquery;
|
||||
|
||||
logger.info(`🚫 제외 필터 적용 (Entity 조인):`, {
|
||||
referenceTable,
|
||||
referenceColumn,
|
||||
sourceColumn,
|
||||
filterColumn,
|
||||
filterValue,
|
||||
excludeSubquery,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ORDER BY 절 구성
|
||||
const orderBy = options.sortBy
|
||||
? `main.${options.sortBy} ${options.sortOrder === "desc" ? "DESC" : "ASC"}`
|
||||
|
|
|
|||
|
|
@ -221,6 +221,7 @@ function ScreenViewPage() {
|
|||
// 모바일 환경에서는 스케일 조정 비활성화 (반응형만 작동)
|
||||
if (isMobile) {
|
||||
setScale(1);
|
||||
setLayoutReady(true); // 모바일에서도 레이아웃 준비 완료 표시
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ const Marker = dynamic(() => import("react-leaflet").then((mod) => mod.Marker),
|
|||
const Popup = dynamic(() => import("react-leaflet").then((mod) => mod.Popup), { ssr: false });
|
||||
const Polygon = dynamic(() => import("react-leaflet").then((mod) => mod.Polygon), { ssr: false });
|
||||
const GeoJSON = dynamic(() => import("react-leaflet").then((mod) => mod.GeoJSON), { ssr: false });
|
||||
const Polyline = dynamic(() => import("react-leaflet").then((mod) => mod.Polyline), { ssr: false });
|
||||
|
||||
// 브이월드 API 키
|
||||
const VWORLD_API_KEY = "97AD30D5-FDC4-3481-99C3-158E36422033";
|
||||
|
|
@ -78,6 +79,13 @@ interface PolygonData {
|
|||
opacity?: number; // 투명도 (0.0 ~ 1.0)
|
||||
}
|
||||
|
||||
// 이동경로 타입
|
||||
interface RoutePoint {
|
||||
lat: number;
|
||||
lng: number;
|
||||
recordedAt: string;
|
||||
}
|
||||
|
||||
export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||
const [markers, setMarkers] = useState<MarkerData[]>([]);
|
||||
const prevMarkersRef = useRef<MarkerData[]>([]); // 이전 마커 위치 저장 (useRef 사용)
|
||||
|
|
@ -86,6 +94,12 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
const [error, setError] = useState<string | null>(null);
|
||||
const [geoJsonData, setGeoJsonData] = useState<any>(null);
|
||||
const [lastRefreshTime, setLastRefreshTime] = useState<Date | null>(null);
|
||||
|
||||
// 이동경로 상태
|
||||
const [routePoints, setRoutePoints] = useState<RoutePoint[]>([]);
|
||||
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
|
||||
const [routeLoading, setRouteLoading] = useState(false);
|
||||
const [routeDate, setRouteDate] = useState<string>(new Date().toISOString().split('T')[0]); // YYYY-MM-DD 형식
|
||||
|
||||
// dataSources를 useMemo로 추출 (circular reference 방지)
|
||||
const dataSources = useMemo(() => {
|
||||
|
|
@ -107,6 +121,70 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
return heading;
|
||||
}, []);
|
||||
|
||||
// 이동경로 로드 함수
|
||||
const loadRoute = useCallback(async (userId: string, date?: string) => {
|
||||
if (!userId) {
|
||||
console.log("🛣️ 이동경로 조회 불가: userId 없음");
|
||||
return;
|
||||
}
|
||||
|
||||
setRouteLoading(true);
|
||||
setSelectedUserId(userId);
|
||||
|
||||
try {
|
||||
// 선택한 날짜 기준으로 이동경로 조회
|
||||
const targetDate = date || routeDate;
|
||||
const startOfDay = `${targetDate}T00:00:00.000Z`;
|
||||
const endOfDay = `${targetDate}T23:59:59.999Z`;
|
||||
|
||||
const query = `SELECT latitude, longitude, recorded_at
|
||||
FROM vehicle_location_history
|
||||
WHERE user_id = '${userId}'
|
||||
AND recorded_at >= '${startOfDay}'
|
||||
AND recorded_at <= '${endOfDay}'
|
||||
ORDER BY recorded_at ASC`;
|
||||
|
||||
console.log("🛣️ 이동경로 쿼리:", query);
|
||||
|
||||
const response = await fetch(getApiUrl("/api/dashboards/execute-query"), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${typeof window !== "undefined" ? localStorage.getItem("authToken") || "" : ""}`,
|
||||
},
|
||||
body: JSON.stringify({ query }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
if (result.success && result.data.rows.length > 0) {
|
||||
const points: RoutePoint[] = result.data.rows.map((row: any) => ({
|
||||
lat: parseFloat(row.latitude),
|
||||
lng: parseFloat(row.longitude),
|
||||
recordedAt: row.recorded_at,
|
||||
}));
|
||||
|
||||
console.log(`🛣️ 이동경로 ${points.length}개 포인트 로드 완료`);
|
||||
setRoutePoints(points);
|
||||
} else {
|
||||
console.log("🛣️ 이동경로 데이터 없음");
|
||||
setRoutePoints([]);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("이동경로 로드 실패:", error);
|
||||
setRoutePoints([]);
|
||||
}
|
||||
|
||||
setRouteLoading(false);
|
||||
}, [routeDate]);
|
||||
|
||||
// 이동경로 숨기기
|
||||
const clearRoute = useCallback(() => {
|
||||
setSelectedUserId(null);
|
||||
setRoutePoints([]);
|
||||
}, []);
|
||||
|
||||
// 다중 데이터 소스 로딩
|
||||
const loadMultipleDataSources = useCallback(async () => {
|
||||
if (!dataSources || dataSources.length === 0) {
|
||||
|
|
@ -509,7 +587,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
status: row.status || row.level,
|
||||
description: JSON.stringify(row, null, 2), // 항상 전체 row 객체를 JSON으로 저장
|
||||
source: sourceName,
|
||||
color: dataSource?.markerColor || "#3b82f6", // 사용자 지정 색상 또는 기본 파랑
|
||||
color: row.marker_color || row.color || dataSource?.markerColor || "#3b82f6", // 쿼리 색상 > 설정 색상 > 기본 파랑
|
||||
});
|
||||
} else {
|
||||
// 위도/경도가 없는 육지 지역 → 폴리곤으로 추가 (GeoJSON 매칭용)
|
||||
|
|
@ -1005,6 +1083,32 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 이동경로 날짜 선택 */}
|
||||
{selectedUserId && (
|
||||
<div className="flex items-center gap-1 rounded border bg-blue-50 px-2 py-1">
|
||||
<span className="text-xs text-blue-600">🛣️</span>
|
||||
<input
|
||||
type="date"
|
||||
value={routeDate}
|
||||
onChange={(e) => {
|
||||
setRouteDate(e.target.value);
|
||||
if (selectedUserId) {
|
||||
loadRoute(selectedUserId, e.target.value);
|
||||
}
|
||||
}}
|
||||
className="h-6 rounded border-none bg-transparent px-1 text-xs text-blue-600 focus:outline-none"
|
||||
/>
|
||||
<span className="text-xs text-blue-600">
|
||||
({routePoints.length}개)
|
||||
</span>
|
||||
<button
|
||||
onClick={clearRoute}
|
||||
className="ml-1 text-xs text-blue-400 hover:text-blue-600"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
|
@ -1305,6 +1409,14 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
// 트럭 마커
|
||||
// 트럭 아이콘이 오른쪽(90도)을 보고 있으므로, 북쪽(0도)으로 가려면 -90도 회전 필요
|
||||
const rotation = heading - 90;
|
||||
|
||||
// 회전 각도가 90~270도 범위면 차량이 뒤집어짐 (바퀴가 위로)
|
||||
// 이 경우 scaleY(-1)로 상하 반전하여 바퀴가 아래로 오도록 함
|
||||
const normalizedRotation = ((rotation % 360) + 360) % 360;
|
||||
const isFlipped = normalizedRotation > 90 && normalizedRotation < 270;
|
||||
const transformStyle = isFlipped
|
||||
? `translate(-50%, -50%) rotate(${rotation}deg) scaleY(-1)`
|
||||
: `translate(-50%, -50%) rotate(${rotation}deg)`;
|
||||
|
||||
markerIcon = L.divIcon({
|
||||
className: "custom-truck-marker",
|
||||
|
|
@ -1315,7 +1427,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transform: translate(-50%, -50%) rotate(${rotation}deg);
|
||||
transform: ${transformStyle};
|
||||
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.3));
|
||||
">
|
||||
<svg width="48" height="48" viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg">
|
||||
|
|
@ -1528,12 +1640,51 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
<div className="text-muted-foreground border-t pt-2 text-[10px]">
|
||||
{marker.lat.toFixed(6)}, {marker.lng.toFixed(6)}
|
||||
</div>
|
||||
|
||||
{/* 이동경로 버튼 */}
|
||||
{(() => {
|
||||
try {
|
||||
const parsed = JSON.parse(marker.description || "{}");
|
||||
const userId = parsed.user_id;
|
||||
if (userId) {
|
||||
return (
|
||||
<div className="mt-2 border-t pt-2">
|
||||
<button
|
||||
onClick={() => loadRoute(userId)}
|
||||
disabled={routeLoading}
|
||||
className="w-full rounded bg-blue-500 px-2 py-1 text-xs text-white hover:bg-blue-600 disabled:opacity-50"
|
||||
>
|
||||
{routeLoading && selectedUserId === userId
|
||||
? "로딩 중..."
|
||||
: "🛣️ 이동경로 보기"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 이동경로 Polyline */}
|
||||
{routePoints.length > 1 && (
|
||||
<Polyline
|
||||
positions={routePoints.map((p) => [p.lat, p.lng] as [number, number])}
|
||||
pathOptions={{
|
||||
color: "#3b82f6",
|
||||
weight: 4,
|
||||
opacity: 0.8,
|
||||
dashArray: "10, 5",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</MapContainer>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ const TileLayer = dynamic(() => import("react-leaflet").then((mod) => mod.TileLa
|
|||
const Marker = dynamic(() => import("react-leaflet").then((mod) => mod.Marker), { ssr: false });
|
||||
const Popup = dynamic(() => import("react-leaflet").then((mod) => mod.Popup), { ssr: false });
|
||||
const Circle = dynamic(() => import("react-leaflet").then((mod) => mod.Circle), { ssr: false });
|
||||
const Polyline = dynamic(() => import("react-leaflet").then((mod) => mod.Polyline), { ssr: false });
|
||||
|
||||
// 브이월드 API 키
|
||||
const VWORLD_API_KEY = "97AD30D5-FDC4-3481-99C3-158E36422033";
|
||||
|
|
@ -37,6 +38,16 @@ interface Vehicle {
|
|||
status: "active" | "inactive" | "maintenance" | "warning" | "off";
|
||||
speed: number;
|
||||
destination: string;
|
||||
userId?: string; // 이동경로 조회용
|
||||
tripId?: string; // 현재 운행 ID
|
||||
}
|
||||
|
||||
// 이동경로 좌표
|
||||
interface RoutePoint {
|
||||
lat: number;
|
||||
lng: number;
|
||||
recordedAt: string;
|
||||
speed?: number;
|
||||
}
|
||||
|
||||
interface VehicleMapOnlyWidgetProps {
|
||||
|
|
@ -48,6 +59,11 @@ export default function VehicleMapOnlyWidget({ element, refreshInterval = 30000
|
|||
const [vehicles, setVehicles] = useState<Vehicle[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [lastUpdate, setLastUpdate] = useState<Date>(new Date());
|
||||
|
||||
// 이동경로 상태
|
||||
const [selectedVehicle, setSelectedVehicle] = useState<Vehicle | null>(null);
|
||||
const [routePoints, setRoutePoints] = useState<RoutePoint[]>([]);
|
||||
const [isRouteLoading, setIsRouteLoading] = useState(false);
|
||||
|
||||
const loadVehicles = async () => {
|
||||
setIsLoading(true);
|
||||
|
|
@ -121,6 +137,8 @@ export default function VehicleMapOnlyWidget({ element, refreshInterval = 30000
|
|||
: "inactive",
|
||||
speed: parseFloat(row.speed) || 0,
|
||||
destination: row.destination || "대기 중",
|
||||
userId: row.user_id || row.userId || undefined,
|
||||
tripId: row.trip_id || row.tripId || undefined,
|
||||
};
|
||||
})
|
||||
// 유효한 위도/경도가 있는 차량만 필터링
|
||||
|
|
@ -140,6 +158,78 @@ export default function VehicleMapOnlyWidget({ element, refreshInterval = 30000
|
|||
setIsLoading(false);
|
||||
};
|
||||
|
||||
// 이동경로 로드 함수
|
||||
const loadRoute = async (vehicle: Vehicle) => {
|
||||
if (!vehicle.userId && !vehicle.tripId) {
|
||||
console.log("🛣️ 이동경로 조회 불가: userId 또는 tripId 없음");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsRouteLoading(true);
|
||||
setSelectedVehicle(vehicle);
|
||||
|
||||
try {
|
||||
// 오늘 날짜 기준으로 최근 이동경로 조회
|
||||
const today = new Date();
|
||||
const startOfDay = new Date(today.getFullYear(), today.getMonth(), today.getDate()).toISOString();
|
||||
|
||||
// trip_id가 있으면 해당 운행만, 없으면 user_id로 오늘 전체 조회
|
||||
let query = "";
|
||||
if (vehicle.tripId) {
|
||||
query = `SELECT latitude, longitude, speed, recorded_at
|
||||
FROM vehicle_location_history
|
||||
WHERE trip_id = '${vehicle.tripId}'
|
||||
ORDER BY recorded_at ASC`;
|
||||
} else if (vehicle.userId) {
|
||||
query = `SELECT latitude, longitude, speed, recorded_at
|
||||
FROM vehicle_location_history
|
||||
WHERE user_id = '${vehicle.userId}'
|
||||
AND recorded_at >= '${startOfDay}'
|
||||
ORDER BY recorded_at ASC`;
|
||||
}
|
||||
|
||||
console.log("🛣️ 이동경로 쿼리:", query);
|
||||
|
||||
const response = await fetch(getApiUrl("/api/dashboards/execute-query"), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${typeof window !== "undefined" ? localStorage.getItem("authToken") || "" : ""}`,
|
||||
},
|
||||
body: JSON.stringify({ query }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
if (result.success && result.data.rows.length > 0) {
|
||||
const points: RoutePoint[] = result.data.rows.map((row: any) => ({
|
||||
lat: parseFloat(row.latitude),
|
||||
lng: parseFloat(row.longitude),
|
||||
recordedAt: row.recorded_at,
|
||||
speed: row.speed ? parseFloat(row.speed) : undefined,
|
||||
}));
|
||||
|
||||
console.log(`🛣️ 이동경로 ${points.length}개 포인트 로드 완료`);
|
||||
setRoutePoints(points);
|
||||
} else {
|
||||
console.log("🛣️ 이동경로 데이터 없음");
|
||||
setRoutePoints([]);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("이동경로 로드 실패:", error);
|
||||
setRoutePoints([]);
|
||||
}
|
||||
|
||||
setIsRouteLoading(false);
|
||||
};
|
||||
|
||||
// 이동경로 숨기기
|
||||
const clearRoute = () => {
|
||||
setSelectedVehicle(null);
|
||||
setRoutePoints([]);
|
||||
};
|
||||
|
||||
// useEffect는 항상 같은 순서로 호출되어야 함 (early return 전에 배치)
|
||||
useEffect(() => {
|
||||
loadVehicles();
|
||||
|
|
@ -220,6 +310,19 @@ export default function VehicleMapOnlyWidget({ element, refreshInterval = 30000
|
|||
keepBuffer={2}
|
||||
/>
|
||||
|
||||
{/* 이동경로 Polyline */}
|
||||
{routePoints.length > 1 && (
|
||||
<Polyline
|
||||
positions={routePoints.map((p) => [p.lat, p.lng] as [number, number])}
|
||||
pathOptions={{
|
||||
color: "#3b82f6",
|
||||
weight: 4,
|
||||
opacity: 0.8,
|
||||
dashArray: "10, 5",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 차량 마커 */}
|
||||
{vehicles.map((vehicle) => (
|
||||
<React.Fragment key={vehicle.id}>
|
||||
|
|
@ -248,6 +351,20 @@ export default function VehicleMapOnlyWidget({ element, refreshInterval = 30000
|
|||
<div>
|
||||
<strong>목적지:</strong> {vehicle.destination}
|
||||
</div>
|
||||
{/* 이동경로 버튼 */}
|
||||
{(vehicle.userId || vehicle.tripId) && (
|
||||
<div className="mt-2 border-t pt-2">
|
||||
<button
|
||||
onClick={() => loadRoute(vehicle)}
|
||||
disabled={isRouteLoading}
|
||||
className="w-full rounded bg-blue-500 px-2 py-1 text-xs text-white hover:bg-blue-600 disabled:opacity-50"
|
||||
>
|
||||
{isRouteLoading && selectedVehicle?.id === vehicle.id
|
||||
? "로딩 중..."
|
||||
: "🛣️ 이동경로 보기"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
|
|
@ -271,6 +388,24 @@ export default function VehicleMapOnlyWidget({ element, refreshInterval = 30000
|
|||
<div className="text-xs text-foreground">데이터를 연결하세요</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 이동경로 정보 표시 - 상단으로 이동하여 주석 처리 */}
|
||||
{/* {selectedVehicle && routePoints.length > 0 && (
|
||||
<div className="absolute bottom-2 right-2 z-[1000] rounded-lg bg-blue-500/90 p-2 shadow-lg backdrop-blur-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-xs text-white">
|
||||
<div className="font-semibold">🛣️ {selectedVehicle.name} 이동경로</div>
|
||||
<div>{routePoints.length}개 포인트</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={clearRoute}
|
||||
className="rounded bg-white/20 px-2 py-1 text-xs text-white hover:bg-white/30"
|
||||
>
|
||||
숨기기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)} */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -423,28 +423,80 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
|||
const uiMenus = convertMenuToUI(currentMenus, user as ExtendedUserInfo);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-white">
|
||||
{/* 모바일 사이드바 오버레이 */}
|
||||
{sidebarOpen && isMobile && (
|
||||
<div className="fixed inset-0 z-30 bg-black/50 lg:hidden" onClick={() => setSidebarOpen(false)} />
|
||||
<div className="flex h-screen flex-col bg-white">
|
||||
{/* 모바일 헤더 - 모바일에서만 표시 */}
|
||||
{isMobile && (
|
||||
<header className="fixed top-0 left-0 right-0 z-50 flex h-14 items-center justify-between border-b border-slate-200 bg-white px-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* 햄버거 메뉴 버튼 */}
|
||||
<SideMenu onSidebarToggle={() => setSidebarOpen(!sidebarOpen)} />
|
||||
<Logo />
|
||||
</div>
|
||||
{/* 사용자 드롭다운 */}
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="flex items-center gap-2 rounded-lg px-2 py-1 transition-colors hover:bg-slate-100">
|
||||
<div className="relative flex h-8 w-8 shrink-0 overflow-hidden rounded-full">
|
||||
{user.photo && user.photo.trim() !== "" && user.photo !== "null" ? (
|
||||
<img
|
||||
src={user.photo}
|
||||
alt={user.userName || "User"}
|
||||
className="aspect-square h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center rounded-full bg-slate-200 text-sm font-semibold text-slate-700">
|
||||
{user.userName?.substring(0, 1)?.toUpperCase() || "U"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56" align="end">
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-sm leading-none font-medium">
|
||||
{user.userName || "사용자"}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs leading-none">
|
||||
{user.deptName || user.email || user.userId}
|
||||
</p>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={openProfileModal}>
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
<span>프로필</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleLogout}>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
<span>로그아웃</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</header>
|
||||
)}
|
||||
|
||||
{/* 왼쪽 사이드바 */}
|
||||
<aside
|
||||
className={`${
|
||||
isMobile
|
||||
? (sidebarOpen ? "translate-x-0" : "-translate-x-full") + " fixed top-0 left-0 z-40"
|
||||
: "relative z-auto translate-x-0"
|
||||
} flex h-screen w-[200px] flex-col border-r border-slate-200 bg-white transition-transform duration-300`}
|
||||
>
|
||||
{/* 사이드바 최상단 - 로고 + 모바일 햄버거 메뉴 */}
|
||||
<div className="flex h-14 items-center justify-between border-b border-slate-200 px-4">
|
||||
<Logo />
|
||||
{/* 모바일 햄버거 메뉴 버튼 */}
|
||||
<div className="lg:hidden">
|
||||
<SideMenu onSidebarToggle={() => setSidebarOpen(!sidebarOpen)} />
|
||||
</div>
|
||||
</div>
|
||||
{/* 메인 컨테이너 - 모바일에서는 헤더 높이만큼 패딩 */}
|
||||
<div className={`flex flex-1 ${isMobile ? "pt-14" : ""}`}>
|
||||
{/* 모바일 사이드바 오버레이 */}
|
||||
{sidebarOpen && isMobile && (
|
||||
<div className="fixed inset-0 z-30 bg-black/50 lg:hidden" onClick={() => setSidebarOpen(false)} />
|
||||
)}
|
||||
|
||||
{/* 왼쪽 사이드바 */}
|
||||
<aside
|
||||
className={`${
|
||||
isMobile
|
||||
? (sidebarOpen ? "translate-x-0" : "-translate-x-full") + " fixed top-14 left-0 z-40 h-[calc(100vh-56px)]"
|
||||
: "relative z-auto h-screen translate-x-0"
|
||||
} flex w-[200px] flex-col border-r border-slate-200 bg-white transition-transform duration-300`}
|
||||
>
|
||||
{/* 사이드바 최상단 - 로고 (데스크톱에서만 표시) */}
|
||||
{!isMobile && (
|
||||
<div className="flex h-14 items-center justify-between border-b border-slate-200 px-4">
|
||||
<Logo />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Admin/User 모드 전환 버튼 (관리자만) */}
|
||||
{((user as ExtendedUserInfo)?.userType === "SUPER_ADMIN" ||
|
||||
|
|
@ -565,10 +617,11 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
|||
</div>
|
||||
</aside>
|
||||
|
||||
{/* 가운데 컨텐츠 영역 - 스크롤 가능 */}
|
||||
<main className="h-screen min-w-0 flex-1 overflow-auto bg-white">
|
||||
{children}
|
||||
</main>
|
||||
{/* 가운데 컨텐츠 영역 - 스크롤 가능 */}
|
||||
<main className={`min-w-0 flex-1 overflow-auto bg-white ${isMobile ? "h-[calc(100vh-56px)]" : "h-screen"}`}>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* 프로필 수정 모달 */}
|
||||
<ProfileModal
|
||||
|
|
|
|||
|
|
@ -197,11 +197,12 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
|||
const existing = savedRules.find((r) => r.ruleId === currentRule.ruleId);
|
||||
|
||||
// 저장 전에 현재 화면의 테이블명과 menuObjid 자동 설정
|
||||
// 메뉴 기반으로 채번규칙 관리 (menuObjid로 필터링)
|
||||
const ruleToSave = {
|
||||
...currentRule,
|
||||
scopeType: "table" as const, // ⚠️ 임시: DB 제약 조건 때문에 table 유지
|
||||
tableName: currentTableName || currentRule.tableName || null, // 현재 테이블명 자동 설정 (빈 값은 null)
|
||||
menuObjid: menuObjid || currentRule.menuObjid || null, // 🆕 메뉴 OBJID 설정 (필터링용)
|
||||
scopeType: "menu" as const, // 메뉴 기반 채번규칙
|
||||
tableName: currentTableName || currentRule.tableName || null, // 현재 테이블명 (참고용)
|
||||
menuObjid: menuObjid || currentRule.menuObjid || null, // 메뉴 OBJID (필터링 기준)
|
||||
};
|
||||
|
||||
console.log("💾 채번 규칙 저장:", {
|
||||
|
|
|
|||
|
|
@ -69,6 +69,14 @@ export const entityJoinApi = {
|
|||
}>;
|
||||
screenEntityConfigs?: Record<string, any>; // 🎯 화면별 엔티티 설정
|
||||
dataFilter?: any; // 🆕 데이터 필터
|
||||
excludeFilter?: {
|
||||
enabled: boolean;
|
||||
referenceTable: string;
|
||||
referenceColumn: string;
|
||||
sourceColumn: string;
|
||||
filterColumn?: string;
|
||||
filterValue?: any;
|
||||
}; // 🆕 제외 필터 (다른 테이블에 이미 존재하는 데이터 제외)
|
||||
} = {},
|
||||
): Promise<EntityJoinResponse> => {
|
||||
// 🔒 멀티테넌시: company_code 자동 필터링 활성화
|
||||
|
|
@ -90,6 +98,7 @@ export const entityJoinApi = {
|
|||
screenEntityConfigs: params.screenEntityConfigs ? JSON.stringify(params.screenEntityConfigs) : undefined, // 🎯 화면별 엔티티 설정
|
||||
autoFilter: JSON.stringify(autoFilter), // 🔒 멀티테넌시 필터링
|
||||
dataFilter: params.dataFilter ? JSON.stringify(params.dataFilter) : undefined, // 🆕 데이터 필터
|
||||
excludeFilter: params.excludeFilter ? JSON.stringify(params.excludeFilter) : undefined, // 🆕 제외 필터
|
||||
},
|
||||
});
|
||||
return response.data.data;
|
||||
|
|
|
|||
|
|
@ -170,8 +170,9 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
}
|
||||
};
|
||||
|
||||
// 🆕 disabledFields 체크
|
||||
const isFieldDisabled = props.disabledFields?.includes(columnName) || (component as any).readonly;
|
||||
// 🆕 disabledFields 체크 + readonly 체크
|
||||
const isFieldDisabled = props.disabledFields?.includes(columnName) || (component as any).disabled;
|
||||
const isFieldReadonly = (component as any).readonly || (component as any).componentConfig?.readonly;
|
||||
|
||||
return (
|
||||
<CategorySelectComponent
|
||||
|
|
@ -182,6 +183,7 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
placeholder={component.componentConfig?.placeholder || "선택하세요"}
|
||||
required={(component as any).required}
|
||||
disabled={isFieldDisabled}
|
||||
readonly={isFieldReadonly}
|
||||
className="w-full"
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -42,10 +42,26 @@ export function AutocompleteSearchInputComponent({
|
|||
// config prop 우선, 없으면 개별 prop 사용
|
||||
const tableName = config?.tableName || propTableName || "";
|
||||
const displayField = config?.displayField || propDisplayField || "";
|
||||
const displayFields = config?.displayFields || (displayField ? [displayField] : []); // 다중 표시 필드
|
||||
const displaySeparator = config?.displaySeparator || " → "; // 구분자
|
||||
const valueField = config?.valueField || propValueField || "";
|
||||
const searchFields = config?.searchFields || propSearchFields || [displayField];
|
||||
const searchFields = config?.searchFields || propSearchFields || displayFields; // 검색 필드도 다중 표시 필드 사용
|
||||
const placeholder = config?.placeholder || propPlaceholder || "검색...";
|
||||
|
||||
// 다중 필드 값을 조합하여 표시 문자열 생성
|
||||
const getDisplayValue = (item: EntitySearchResult): string => {
|
||||
if (displayFields.length > 1) {
|
||||
// 여러 필드를 구분자로 조합
|
||||
const values = displayFields
|
||||
.map((field) => item[field])
|
||||
.filter((v) => v !== null && v !== undefined && v !== "")
|
||||
.map((v) => String(v));
|
||||
return values.join(displaySeparator);
|
||||
}
|
||||
// 단일 필드
|
||||
return item[displayField] || "";
|
||||
};
|
||||
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [selectedData, setSelectedData] = useState<EntitySearchResult | null>(null);
|
||||
|
|
@ -115,7 +131,7 @@ export function AutocompleteSearchInputComponent({
|
|||
|
||||
const handleSelect = (item: EntitySearchResult) => {
|
||||
setSelectedData(item);
|
||||
setInputValue(item[displayField] || "");
|
||||
setInputValue(getDisplayValue(item));
|
||||
|
||||
console.log("🔍 AutocompleteSearchInput handleSelect:", {
|
||||
item,
|
||||
|
|
@ -239,7 +255,7 @@ export function AutocompleteSearchInputComponent({
|
|||
onClick={() => handleSelect(item)}
|
||||
className="w-full px-3 py-2 text-left text-xs transition-colors hover:bg-accent sm:text-sm"
|
||||
>
|
||||
<div className="font-medium">{item[displayField]}</div>
|
||||
<div className="font-medium">{getDisplayValue(item)}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -184,52 +184,118 @@ export function AutocompleteSearchInputConfigPanel({
|
|||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* 2. 표시 필드 선택 */}
|
||||
{/* 2. 표시 필드 선택 (다중 선택 가능) */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-semibold sm:text-sm">2. 표시 필드 *</Label>
|
||||
<Popover open={openDisplayFieldCombo} onOpenChange={setOpenDisplayFieldCombo}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={openDisplayFieldCombo}
|
||||
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
||||
disabled={!localConfig.tableName || isLoadingSourceColumns}
|
||||
>
|
||||
{localConfig.displayField
|
||||
? sourceTableColumns.find((c) => c.columnName === localConfig.displayField)?.displayName || localConfig.displayField
|
||||
: isLoadingSourceColumns ? "로딩 중..." : "사용자에게 보여줄 필드"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="필드 검색..." className="text-xs sm:text-sm" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-xs sm:text-sm">필드를 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{sourceTableColumns.map((column) => (
|
||||
<CommandItem
|
||||
key={column.columnName}
|
||||
value={column.columnName}
|
||||
onSelect={() => {
|
||||
updateConfig({ displayField: column.columnName });
|
||||
setOpenDisplayFieldCombo(false);
|
||||
<Label className="text-xs font-semibold sm:text-sm">2. 표시 필드 * (여러 개 선택 가능)</Label>
|
||||
<div className="space-y-2">
|
||||
{/* 선택된 필드 표시 */}
|
||||
{(localConfig.displayFields && localConfig.displayFields.length > 0) ? (
|
||||
<div className="flex flex-wrap gap-1 rounded-md border p-2 min-h-[40px]">
|
||||
{localConfig.displayFields.map((fieldName) => {
|
||||
const col = sourceTableColumns.find((c) => c.columnName === fieldName);
|
||||
return (
|
||||
<span
|
||||
key={fieldName}
|
||||
className="inline-flex items-center gap-1 rounded-md bg-primary/10 px-2 py-1 text-xs"
|
||||
>
|
||||
{col?.displayName || fieldName}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const newFields = localConfig.displayFields?.filter((f) => f !== fieldName) || [];
|
||||
updateConfig({
|
||||
displayFields: newFields,
|
||||
displayField: newFields[0] || "", // 첫 번째 필드를 기본 displayField로
|
||||
});
|
||||
}}
|
||||
className="text-xs sm:text-sm"
|
||||
className="hover:text-destructive"
|
||||
>
|
||||
<Check className={cn("mr-2 h-4 w-4", localConfig.displayField === column.columnName ? "opacity-100" : "opacity-0")} />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{column.displayName || column.columnName}</span>
|
||||
{column.displayName && <span className="text-[10px] text-gray-500">{column.columnName}</span>}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border border-dashed p-2 text-center text-xs text-muted-foreground">
|
||||
아래에서 표시할 필드를 선택하세요
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 필드 선택 드롭다운 */}
|
||||
<Popover open={openDisplayFieldCombo} onOpenChange={setOpenDisplayFieldCombo}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={openDisplayFieldCombo}
|
||||
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
||||
disabled={!localConfig.tableName || isLoadingSourceColumns}
|
||||
>
|
||||
{isLoadingSourceColumns ? "로딩 중..." : "필드 추가..."}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="필드 검색..." className="text-xs sm:text-sm" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-xs sm:text-sm">필드를 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{sourceTableColumns.map((column) => {
|
||||
const isSelected = localConfig.displayFields?.includes(column.columnName);
|
||||
return (
|
||||
<CommandItem
|
||||
key={column.columnName}
|
||||
value={column.columnName}
|
||||
onSelect={() => {
|
||||
const currentFields = localConfig.displayFields || [];
|
||||
let newFields: string[];
|
||||
if (isSelected) {
|
||||
newFields = currentFields.filter((f) => f !== column.columnName);
|
||||
} else {
|
||||
newFields = [...currentFields, column.columnName];
|
||||
}
|
||||
updateConfig({
|
||||
displayFields: newFields,
|
||||
displayField: newFields[0] || "", // 첫 번째 필드를 기본 displayField로
|
||||
});
|
||||
}}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
<Check className={cn("mr-2 h-4 w-4", isSelected ? "opacity-100" : "opacity-0")} />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{column.displayName || column.columnName}</span>
|
||||
{column.displayName && <span className="text-[10px] text-gray-500">{column.columnName}</span>}
|
||||
</div>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* 구분자 설정 */}
|
||||
{localConfig.displayFields && localConfig.displayFields.length > 1 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-xs whitespace-nowrap">구분자:</Label>
|
||||
<Input
|
||||
value={localConfig.displaySeparator || " → "}
|
||||
onChange={(e) => updateConfig({ displaySeparator: e.target.value })}
|
||||
placeholder=" → "
|
||||
className="h-7 w-20 text-xs text-center"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
미리보기: {localConfig.displayFields.map((f) => {
|
||||
const col = sourceTableColumns.find((c) => c.columnName === f);
|
||||
return col?.displayName || f;
|
||||
}).join(localConfig.displaySeparator || " → ")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3. 저장 대상 테이블 선택 */}
|
||||
|
|
@ -419,7 +485,9 @@ export function AutocompleteSearchInputConfigPanel({
|
|||
<strong>외부 테이블:</strong> {localConfig.tableName}
|
||||
</p>
|
||||
<p>
|
||||
<strong>표시 필드:</strong> {localConfig.displayField}
|
||||
<strong>표시 필드:</strong> {localConfig.displayFields?.length
|
||||
? localConfig.displayFields.join(localConfig.displaySeparator || " → ")
|
||||
: localConfig.displayField}
|
||||
</p>
|
||||
<p>
|
||||
<strong>저장 테이블:</strong> {localConfig.targetTable}
|
||||
|
|
|
|||
|
|
@ -29,5 +29,8 @@ export interface AutocompleteSearchInputConfig {
|
|||
fieldMappings?: FieldMapping[]; // 매핑할 필드 목록
|
||||
// 저장 대상 테이블 (간소화 버전)
|
||||
targetTable?: string;
|
||||
// 🆕 다중 표시 필드 설정 (여러 컬럼 조합)
|
||||
displayFields?: string[]; // 여러 컬럼을 조합하여 표시
|
||||
displaySeparator?: string; // 구분자 (기본값: " - ")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -663,9 +663,29 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
return;
|
||||
}
|
||||
|
||||
// 🆕 modalDataStore에서 선택된 데이터 가져오기 (분할 패널 등에서 선택한 데이터)
|
||||
let effectiveSelectedRowsData = selectedRowsData;
|
||||
if ((!selectedRowsData || selectedRowsData.length === 0) && effectiveTableName) {
|
||||
try {
|
||||
const { useModalDataStore } = await import("@/stores/modalDataStore");
|
||||
const dataRegistry = useModalDataStore.getState().dataRegistry;
|
||||
const modalData = dataRegistry[effectiveTableName];
|
||||
if (modalData && modalData.length > 0) {
|
||||
effectiveSelectedRowsData = modalData;
|
||||
console.log("🔗 [ButtonPrimaryComponent] modalDataStore에서 선택된 데이터 가져옴:", {
|
||||
tableName: effectiveTableName,
|
||||
count: modalData.length,
|
||||
data: modalData,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("modalDataStore 접근 실패:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// 삭제 액션인데 선택된 데이터가 없으면 경고 메시지 표시하고 중단
|
||||
const hasDataToDelete =
|
||||
(selectedRowsData && selectedRowsData.length > 0) || (flowSelectedData && flowSelectedData.length > 0);
|
||||
(effectiveSelectedRowsData && effectiveSelectedRowsData.length > 0) || (flowSelectedData && flowSelectedData.length > 0);
|
||||
|
||||
if (processedConfig.action.type === "delete" && !hasDataToDelete) {
|
||||
toast.warning("삭제할 항목을 먼저 선택해주세요.");
|
||||
|
|
@ -724,9 +744,9 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
onClose,
|
||||
onFlowRefresh, // 플로우 새로고침 콜백 추가
|
||||
onSave: finalOnSave, // 🆕 EditModal의 handleSave 콜백 (props에서도 추출)
|
||||
// 테이블 선택된 행 정보 추가
|
||||
// 테이블 선택된 행 정보 추가 (modalDataStore에서 가져온 데이터 우선)
|
||||
selectedRows,
|
||||
selectedRowsData,
|
||||
selectedRowsData: effectiveSelectedRowsData,
|
||||
// 테이블 정렬 정보 추가
|
||||
sortBy, // 🆕 정렬 컬럼
|
||||
sortOrder, // 🆕 정렬 방향
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect, useState, useMemo, useCallback } from "react";
|
||||
import React, { useEffect, useState, useMemo, useCallback, useRef } from "react";
|
||||
import { ComponentRendererProps } from "@/types/component";
|
||||
import { CardDisplayConfig } from "./types";
|
||||
import { tableTypeApi } from "@/lib/api/screen";
|
||||
|
|
@ -13,6 +13,8 @@ import { Badge } from "@/components/ui/badge";
|
|||
import { useScreenContextOptional } from "@/contexts/ScreenContext";
|
||||
import { useSplitPanelContext } from "@/contexts/SplitPanelContext";
|
||||
import { useModalDataStore } from "@/stores/modalDataStore";
|
||||
import { useTableOptions } from "@/contexts/TableOptionsContext";
|
||||
import { TableFilter, ColumnVisibility, TableColumn } from "@/types/table-options";
|
||||
|
||||
export interface CardDisplayComponentProps extends ComponentRendererProps {
|
||||
config?: CardDisplayConfig;
|
||||
|
|
@ -48,11 +50,32 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
|
|||
const splitPanelContext = useSplitPanelContext();
|
||||
const splitPanelPosition = screenContext?.splitPanelPosition;
|
||||
|
||||
// TableOptions Context (검색 필터 위젯 연동용)
|
||||
let tableOptionsContext: ReturnType<typeof useTableOptions> | null = null;
|
||||
try {
|
||||
tableOptionsContext = useTableOptions();
|
||||
} catch (e) {
|
||||
// Context가 없으면 (디자이너 모드) 무시
|
||||
}
|
||||
|
||||
// 테이블 데이터 상태 관리
|
||||
const [loadedTableData, setLoadedTableData] = useState<any[]>([]);
|
||||
const [loadedTableColumns, setLoadedTableColumns] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 필터 상태 (검색 필터 위젯에서 전달받은 필터)
|
||||
const [filters, setFiltersInternal] = useState<TableFilter[]>([]);
|
||||
|
||||
// 필터 상태 변경 래퍼 (로깅용)
|
||||
const setFilters = useCallback((newFilters: TableFilter[]) => {
|
||||
console.log("🎴 [CardDisplay] setFilters 호출됨:", {
|
||||
componentId: component.id,
|
||||
filtersCount: newFilters.length,
|
||||
filters: newFilters,
|
||||
});
|
||||
setFiltersInternal(newFilters);
|
||||
}, [component.id]);
|
||||
|
||||
// 카테고리 매핑 상태 (카테고리 코드 -> 라벨/색상)
|
||||
const [columnMeta, setColumnMeta] = useState<
|
||||
Record<string, { webType?: string; codeCategory?: string; inputType?: string }>
|
||||
|
|
@ -380,6 +403,195 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
|
|||
}
|
||||
}, [screenContext, component.id, dataProvider]);
|
||||
|
||||
// TableOptionsContext에 테이블 등록 (검색 필터 위젯 연동용)
|
||||
const tableId = `card-display-${component.id}`;
|
||||
const tableNameToUse = tableName || component.componentConfig?.tableName || '';
|
||||
const tableLabel = component.componentConfig?.title || component.label || "카드 디스플레이";
|
||||
|
||||
// ref로 최신 데이터 참조 (useCallback 의존성 문제 해결)
|
||||
const loadedTableDataRef = useRef(loadedTableData);
|
||||
const categoryMappingsRef = useRef(categoryMappings);
|
||||
|
||||
useEffect(() => {
|
||||
loadedTableDataRef.current = loadedTableData;
|
||||
}, [loadedTableData]);
|
||||
|
||||
useEffect(() => {
|
||||
categoryMappingsRef.current = categoryMappings;
|
||||
}, [categoryMappings]);
|
||||
|
||||
// 필터가 변경되면 데이터 다시 로드 (테이블 리스트와 동일한 패턴)
|
||||
// 초기 로드 여부 추적
|
||||
const isInitialLoadRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!tableNameToUse || isDesignMode) return;
|
||||
|
||||
// 초기 로드는 별도 useEffect에서 처리하므로 스킵
|
||||
if (isInitialLoadRef.current) {
|
||||
isInitialLoadRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const loadFilteredData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// 필터 값을 검색 파라미터로 변환
|
||||
const searchParams: Record<string, any> = {};
|
||||
filters.forEach(filter => {
|
||||
if (filter.value !== undefined && filter.value !== null && filter.value !== '') {
|
||||
searchParams[filter.columnName] = filter.value;
|
||||
}
|
||||
});
|
||||
|
||||
console.log("🔍 [CardDisplay] 필터 적용 데이터 로드:", {
|
||||
tableName: tableNameToUse,
|
||||
filtersCount: filters.length,
|
||||
searchParams,
|
||||
});
|
||||
|
||||
// search 파라미터로 검색 조건 전달 (API 스펙에 맞게)
|
||||
const dataResponse = await tableTypeApi.getTableData(tableNameToUse, {
|
||||
page: 1,
|
||||
size: 50,
|
||||
search: searchParams,
|
||||
});
|
||||
|
||||
setLoadedTableData(dataResponse.data);
|
||||
|
||||
// 데이터 건수 업데이트
|
||||
if (tableOptionsContext) {
|
||||
tableOptionsContext.updateTableDataCount(tableId, dataResponse.data?.length || 0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ [CardDisplay] 필터 적용 실패:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 필터 변경 시 항상 데이터 다시 로드 (빈 필터 = 전체 데이터)
|
||||
loadFilteredData();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [filters, tableNameToUse, isDesignMode, tableId]);
|
||||
|
||||
// 컬럼 고유 값 조회 함수 (select 타입 필터용)
|
||||
const getColumnUniqueValues = useCallback(async (columnName: string): Promise<Array<{ label: string; value: string }>> => {
|
||||
if (!tableNameToUse) return [];
|
||||
|
||||
try {
|
||||
// 현재 로드된 데이터에서 고유 값 추출
|
||||
const uniqueValues = new Set<string>();
|
||||
loadedTableDataRef.current.forEach(row => {
|
||||
const value = row[columnName];
|
||||
if (value !== null && value !== undefined && value !== '') {
|
||||
uniqueValues.add(String(value));
|
||||
}
|
||||
});
|
||||
|
||||
// 카테고리 매핑이 있으면 라벨 적용
|
||||
const mapping = categoryMappingsRef.current[columnName];
|
||||
return Array.from(uniqueValues).map(value => ({
|
||||
value,
|
||||
label: mapping?.[value]?.label || value,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error(`❌ [CardDisplay] 고유 값 조회 실패: ${columnName}`, error);
|
||||
return [];
|
||||
}
|
||||
}, [tableNameToUse]);
|
||||
|
||||
// TableOptionsContext에 등록
|
||||
// registerTable과 unregisterTable 함수 참조 저장 (의존성 안정화)
|
||||
const registerTableRef = useRef(tableOptionsContext?.registerTable);
|
||||
const unregisterTableRef = useRef(tableOptionsContext?.unregisterTable);
|
||||
|
||||
// setFiltersInternal을 ref로 저장 (등록 시 최신 함수 사용)
|
||||
const setFiltersRef = useRef(setFiltersInternal);
|
||||
const getColumnUniqueValuesRef = useRef(getColumnUniqueValues);
|
||||
|
||||
useEffect(() => {
|
||||
registerTableRef.current = tableOptionsContext?.registerTable;
|
||||
unregisterTableRef.current = tableOptionsContext?.unregisterTable;
|
||||
}, [tableOptionsContext]);
|
||||
|
||||
useEffect(() => {
|
||||
setFiltersRef.current = setFiltersInternal;
|
||||
}, [setFiltersInternal]);
|
||||
|
||||
useEffect(() => {
|
||||
getColumnUniqueValuesRef.current = getColumnUniqueValues;
|
||||
}, [getColumnUniqueValues]);
|
||||
|
||||
// 테이블 등록 (한 번만 실행, 컬럼 변경 시에만 재등록)
|
||||
const columnsKey = JSON.stringify(loadedTableColumns.map((col: any) => col.columnName || col.column_name));
|
||||
|
||||
useEffect(() => {
|
||||
if (!registerTableRef.current || !unregisterTableRef.current) return;
|
||||
if (isDesignMode || !tableNameToUse || loadedTableColumns.length === 0) return;
|
||||
|
||||
// 컬럼 정보를 TableColumn 형식으로 변환
|
||||
const columns: TableColumn[] = loadedTableColumns.map((col: any) => ({
|
||||
columnName: col.columnName || col.column_name,
|
||||
columnLabel: col.displayName || col.columnLabel || col.column_label || col.columnName || col.column_name,
|
||||
inputType: columnMeta[col.columnName || col.column_name]?.inputType || 'text',
|
||||
visible: true,
|
||||
width: 200,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
}));
|
||||
|
||||
// onFilterChange는 ref를 통해 최신 함수를 호출하는 래퍼 사용
|
||||
const onFilterChangeWrapper = (newFilters: TableFilter[]) => {
|
||||
console.log("🎴 [CardDisplay] onFilterChange 래퍼 호출:", {
|
||||
tableId,
|
||||
filtersCount: newFilters.length,
|
||||
});
|
||||
setFiltersRef.current(newFilters);
|
||||
};
|
||||
|
||||
const getColumnUniqueValuesWrapper = async (columnName: string) => {
|
||||
return getColumnUniqueValuesRef.current(columnName);
|
||||
};
|
||||
|
||||
const registration = {
|
||||
tableId,
|
||||
label: tableLabel,
|
||||
tableName: tableNameToUse,
|
||||
columns,
|
||||
dataCount: loadedTableData.length,
|
||||
onFilterChange: onFilterChangeWrapper,
|
||||
onGroupChange: () => {}, // 카드 디스플레이는 그룹핑 미지원
|
||||
onColumnVisibilityChange: () => {}, // 카드 디스플레이는 컬럼 가시성 미지원
|
||||
getColumnUniqueValues: getColumnUniqueValuesWrapper,
|
||||
};
|
||||
|
||||
console.log("📋 [CardDisplay] TableOptionsContext에 등록:", {
|
||||
tableId,
|
||||
tableName: tableNameToUse,
|
||||
columnsCount: columns.length,
|
||||
dataCount: loadedTableData.length,
|
||||
});
|
||||
|
||||
registerTableRef.current(registration);
|
||||
|
||||
const unregister = unregisterTableRef.current;
|
||||
const currentTableId = tableId;
|
||||
|
||||
return () => {
|
||||
console.log("📋 [CardDisplay] TableOptionsContext에서 해제:", currentTableId);
|
||||
unregister(currentTableId);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
isDesignMode,
|
||||
tableId,
|
||||
tableNameToUse,
|
||||
tableLabel,
|
||||
columnsKey, // 컬럼 변경 시에만 재등록
|
||||
]);
|
||||
|
||||
// 로딩 중인 경우 로딩 표시
|
||||
if (loading) {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -52,6 +52,10 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
|||
menuObjid, // 🆕 메뉴 OBJID
|
||||
...props
|
||||
}) => {
|
||||
// 🆕 읽기전용/비활성화 상태 확인
|
||||
const isReadonly = (component as any).readonly || (props as any).readonly || componentConfig?.readonly || false;
|
||||
const isDisabled = (component as any).disabled || (props as any).disabled || componentConfig?.disabled || false;
|
||||
const isFieldDisabled = isDesignMode || isReadonly || isDisabled;
|
||||
// 화면 컨텍스트 (데이터 제공자로 등록)
|
||||
const screenContext = useScreenContextOptional();
|
||||
|
||||
|
|
@ -327,7 +331,7 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
|||
|
||||
// 클릭 이벤트 핸들러 (React Query로 간소화)
|
||||
const handleToggle = () => {
|
||||
if (isDesignMode) return;
|
||||
if (isFieldDisabled) return; // 🆕 읽기전용/비활성화 상태에서는 토글 불가
|
||||
|
||||
// React Query가 자동으로 캐시 관리하므로 수동 새로고침 불필요
|
||||
setIsOpen(!isOpen);
|
||||
|
|
@ -425,7 +429,7 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
|||
value={option.value}
|
||||
checked={selectedValue === option.value}
|
||||
onChange={() => handleOptionSelect(option.value, option.label)}
|
||||
disabled={isDesignMode}
|
||||
disabled={isFieldDisabled}
|
||||
className="border-input text-primary focus:ring-ring h-4 w-4"
|
||||
/>
|
||||
<span className="text-sm">{option.label}</span>
|
||||
|
|
@ -456,12 +460,14 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
|||
placeholder="코드 또는 코드명 입력..."
|
||||
className={cn(
|
||||
"h-10 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 transition-all outline-none",
|
||||
!isDesignMode && "hover:border-orange-400 focus:border-orange-500 focus:ring-2 focus:ring-orange-200",
|
||||
!isFieldDisabled && "hover:border-orange-400 focus:border-orange-500 focus:ring-2 focus:ring-orange-200",
|
||||
isSelected && "ring-2 ring-orange-500",
|
||||
isFieldDisabled && "bg-gray-100 cursor-not-allowed",
|
||||
)}
|
||||
readOnly={isDesignMode}
|
||||
readOnly={isFieldDisabled}
|
||||
disabled={isFieldDisabled}
|
||||
/>
|
||||
{isOpen && !isDesignMode && filteredOptions.length > 0 && (
|
||||
{isOpen && !isFieldDisabled && filteredOptions.length > 0 && (
|
||||
<div className="absolute z-[99999] mt-1 max-h-60 w-full overflow-auto rounded-md border border-gray-300 bg-white shadow-lg">
|
||||
{filteredOptions.map((option, index) => (
|
||||
<div
|
||||
|
|
@ -490,13 +496,14 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
|||
<div className="w-full">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-10 w-full cursor-pointer items-center justify-between rounded-lg border border-gray-300 bg-white px-3 py-2",
|
||||
!isDesignMode && "hover:border-orange-400",
|
||||
"flex h-10 w-full items-center justify-between rounded-lg border border-gray-300 bg-white px-3 py-2",
|
||||
!isFieldDisabled && "cursor-pointer hover:border-orange-400",
|
||||
isSelected && "ring-2 ring-orange-500",
|
||||
isOpen && "border-orange-500",
|
||||
isFieldDisabled && "bg-gray-100 cursor-not-allowed",
|
||||
)}
|
||||
onClick={handleToggle}
|
||||
style={{ pointerEvents: isDesignMode ? "none" : "auto" }}
|
||||
style={{ pointerEvents: isFieldDisabled ? "none" : "auto" }}
|
||||
>
|
||||
<span className={selectedLabel ? "text-gray-900" : "text-gray-500"}>{selectedLabel || placeholder}</span>
|
||||
<svg
|
||||
|
|
@ -508,7 +515,7 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
|||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
{isOpen && !isDesignMode && (
|
||||
{isOpen && !isFieldDisabled && (
|
||||
<div className="absolute z-[99999] mt-1 max-h-60 w-full overflow-auto rounded-md border border-gray-300 bg-white shadow-lg">
|
||||
{isLoadingCodes ? (
|
||||
<div className="bg-white px-3 py-2 text-gray-900">로딩 중...</div>
|
||||
|
|
@ -538,8 +545,9 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
|||
<div
|
||||
className={cn(
|
||||
"box-border flex h-full w-full flex-wrap gap-2 rounded-lg border border-gray-300 bg-white px-3 py-2",
|
||||
!isDesignMode && "hover:border-orange-400",
|
||||
!isFieldDisabled && "hover:border-orange-400",
|
||||
isSelected && "ring-2 ring-orange-500",
|
||||
isFieldDisabled && "bg-gray-100 cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
{selectedValues.map((val, idx) => {
|
||||
|
|
@ -567,8 +575,9 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
|||
type="text"
|
||||
placeholder={selectedValues.length > 0 ? "" : placeholder}
|
||||
className="min-w-[100px] flex-1 border-none bg-transparent outline-none"
|
||||
onClick={() => setIsOpen(true)}
|
||||
readOnly={isDesignMode}
|
||||
onClick={() => !isFieldDisabled && setIsOpen(true)}
|
||||
readOnly={isFieldDisabled}
|
||||
disabled={isFieldDisabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -589,19 +598,22 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
|||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
if (isFieldDisabled) return;
|
||||
setSearchQuery(e.target.value);
|
||||
setIsOpen(true);
|
||||
}}
|
||||
onFocus={() => setIsOpen(true)}
|
||||
onFocus={() => !isFieldDisabled && setIsOpen(true)}
|
||||
placeholder={placeholder}
|
||||
className={cn(
|
||||
"h-10 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 transition-all outline-none",
|
||||
!isDesignMode && "hover:border-orange-400 focus:border-orange-500 focus:ring-2 focus:ring-orange-200",
|
||||
!isFieldDisabled && "hover:border-orange-400 focus:border-orange-500 focus:ring-2 focus:ring-orange-200",
|
||||
isSelected && "ring-2 ring-orange-500",
|
||||
isFieldDisabled && "bg-gray-100 cursor-not-allowed",
|
||||
)}
|
||||
readOnly={isDesignMode}
|
||||
readOnly={isFieldDisabled}
|
||||
disabled={isFieldDisabled}
|
||||
/>
|
||||
{isOpen && !isDesignMode && filteredOptions.length > 0 && (
|
||||
{isOpen && !isFieldDisabled && filteredOptions.length > 0 && (
|
||||
<div className="absolute z-[99999] mt-1 max-h-60 w-full overflow-auto rounded-md border border-gray-300 bg-white shadow-lg">
|
||||
{filteredOptions.map((option, index) => (
|
||||
<div
|
||||
|
|
@ -632,13 +644,14 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
|||
<div className="w-full">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-10 w-full cursor-pointer items-center justify-between rounded-lg border border-gray-300 bg-white px-3 py-2",
|
||||
!isDesignMode && "hover:border-orange-400",
|
||||
"flex h-10 w-full items-center justify-between rounded-lg border border-gray-300 bg-white px-3 py-2",
|
||||
!isFieldDisabled && "cursor-pointer hover:border-orange-400",
|
||||
isSelected && "ring-2 ring-orange-500",
|
||||
isOpen && "border-orange-500",
|
||||
isFieldDisabled && "bg-gray-100 cursor-not-allowed",
|
||||
)}
|
||||
onClick={handleToggle}
|
||||
style={{ pointerEvents: isDesignMode ? "none" : "auto" }}
|
||||
style={{ pointerEvents: isFieldDisabled ? "none" : "auto" }}
|
||||
>
|
||||
<span className={selectedLabel ? "text-gray-900" : "text-gray-500"}>{selectedLabel || placeholder}</span>
|
||||
<svg
|
||||
|
|
@ -650,7 +663,7 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
|||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
{isOpen && !isDesignMode && (
|
||||
{isOpen && !isFieldDisabled && (
|
||||
<div className="absolute z-[99999] mt-1 w-full rounded-md border border-gray-300 bg-white shadow-lg">
|
||||
<input
|
||||
type="text"
|
||||
|
|
@ -690,12 +703,13 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
|||
<div
|
||||
className={cn(
|
||||
"box-border flex w-full flex-wrap items-center gap-2 rounded-lg border border-gray-300 bg-white px-3 py-2",
|
||||
!isDesignMode && "hover:border-orange-400",
|
||||
!isFieldDisabled && "hover:border-orange-400",
|
||||
isSelected && "ring-2 ring-orange-500",
|
||||
isFieldDisabled && "bg-gray-100 cursor-not-allowed",
|
||||
)}
|
||||
onClick={() => !isDesignMode && setIsOpen(true)}
|
||||
onClick={() => !isFieldDisabled && setIsOpen(true)}
|
||||
style={{
|
||||
pointerEvents: isDesignMode ? "none" : "auto",
|
||||
pointerEvents: isFieldDisabled ? "none" : "auto",
|
||||
height: "100%"
|
||||
}}
|
||||
>
|
||||
|
|
@ -726,7 +740,7 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
|||
<span className="text-gray-500">{placeholder}</span>
|
||||
)}
|
||||
</div>
|
||||
{isOpen && !isDesignMode && (
|
||||
{isOpen && !isFieldDisabled && (
|
||||
<div className="absolute z-[99999] mt-1 max-h-60 w-full overflow-auto rounded-md border border-gray-300 bg-white shadow-lg">
|
||||
{(isLoadingCodes || isLoadingCategories) ? (
|
||||
<div className="bg-white px-3 py-2 text-gray-900">로딩 중...</div>
|
||||
|
|
@ -789,13 +803,14 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
|||
<div className="w-full">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-10 w-full cursor-pointer items-center justify-between rounded-lg border border-gray-300 bg-white px-3 py-2",
|
||||
!isDesignMode && "hover:border-orange-400",
|
||||
"flex h-10 w-full items-center justify-between rounded-lg border border-gray-300 bg-white px-3 py-2",
|
||||
!isFieldDisabled && "cursor-pointer hover:border-orange-400",
|
||||
isSelected && "ring-2 ring-orange-500",
|
||||
isOpen && "border-orange-500",
|
||||
isFieldDisabled && "bg-gray-100 cursor-not-allowed",
|
||||
)}
|
||||
onClick={handleToggle}
|
||||
style={{ pointerEvents: isDesignMode ? "none" : "auto" }}
|
||||
style={{ pointerEvents: isFieldDisabled ? "none" : "auto" }}
|
||||
>
|
||||
<span className={selectedLabel ? "text-gray-900" : "text-gray-500"}>{selectedLabel || placeholder}</span>
|
||||
<svg
|
||||
|
|
@ -807,7 +822,7 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
|||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
{isOpen && !isDesignMode && (
|
||||
{isOpen && !isFieldDisabled && (
|
||||
<div className="absolute z-[99999] mt-1 max-h-60 w-full overflow-auto rounded-md border border-gray-300 bg-white shadow-lg">
|
||||
{isLoadingCodes ? (
|
||||
<div className="bg-white px-3 py-2 text-gray-900">로딩 중...</div>
|
||||
|
|
|
|||
|
|
@ -293,8 +293,17 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
) => {
|
||||
if (value === null || value === undefined) return "-";
|
||||
|
||||
// 카테고리 매핑이 있는지 확인
|
||||
const mapping = categoryMappings[columnName];
|
||||
// 🆕 카테고리 매핑 찾기 (여러 키 형태 시도)
|
||||
// 1. 전체 컬럼명 (예: "item_info.material")
|
||||
// 2. 컬럼명만 (예: "material")
|
||||
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);
|
||||
|
|
@ -690,43 +699,69 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
loadLeftCategoryMappings();
|
||||
}, [componentConfig.leftPanel?.tableName, isDesignMode]);
|
||||
|
||||
// 우측 테이블 카테고리 매핑 로드
|
||||
// 우측 테이블 카테고리 매핑 로드 (조인된 테이블 포함)
|
||||
useEffect(() => {
|
||||
const loadRightCategoryMappings = async () => {
|
||||
const rightTableName = componentConfig.rightPanel?.tableName;
|
||||
if (!rightTableName || isDesignMode) return;
|
||||
|
||||
try {
|
||||
// 1. 컬럼 메타 정보 조회
|
||||
const columnsResponse = await tableTypeApi.getColumns(rightTableName);
|
||||
const categoryColumns = columnsResponse.filter((col: any) => col.inputType === "category");
|
||||
|
||||
if (categoryColumns.length === 0) {
|
||||
setRightCategoryMappings({});
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 각 카테고리 컬럼에 대한 값 조회
|
||||
const mappings: Record<string, Record<string, { label: string; color?: string }>> = {};
|
||||
|
||||
for (const col of categoryColumns) {
|
||||
const columnName = col.columnName || col.column_name;
|
||||
try {
|
||||
const response = await apiClient.get(`/table-categories/${rightTableName}/${columnName}/values`);
|
||||
// 🆕 우측 패널 컬럼 설정에서 조인된 테이블 추출
|
||||
const rightColumns = componentConfig.rightPanel?.columns || [];
|
||||
const tablesToLoad = new Set<string>([rightTableName]);
|
||||
|
||||
// 컬럼명에서 테이블명 추출 (예: "item_info.material" -> "item_info")
|
||||
rightColumns.forEach((col: any) => {
|
||||
const colName = col.name || col.columnName;
|
||||
if (colName && colName.includes(".")) {
|
||||
const joinTableName = colName.split(".")[0];
|
||||
tablesToLoad.add(joinTableName);
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
console.log("🔍 우측 패널 카테고리 로드 대상 테이블:", Array.from(tablesToLoad));
|
||||
|
||||
// 각 테이블에 대해 카테고리 매핑 로드
|
||||
for (const tableName of tablesToLoad) {
|
||||
try {
|
||||
// 1. 컬럼 메타 정보 조회
|
||||
const columnsResponse = await tableTypeApi.getColumns(tableName);
|
||||
const categoryColumns = columnsResponse.filter((col: any) => col.inputType === "category");
|
||||
|
||||
// 2. 각 카테고리 컬럼에 대한 값 조회
|
||||
for (const col of categoryColumns) {
|
||||
const columnName = col.columnName || col.column_name;
|
||||
try {
|
||||
const response = await apiClient.get(`/table-categories/${tableName}/${columnName}/values`);
|
||||
|
||||
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 === rightTableName ? columnName : `${tableName}.${columnName}`;
|
||||
mappings[mappingKey] = valueMap;
|
||||
|
||||
// 🆕 컬럼명만으로도 접근할 수 있도록 추가 저장 (모든 테이블)
|
||||
// 기존 매핑이 있으면 병합, 없으면 새로 생성
|
||||
mappings[columnName] = { ...(mappings[columnName] || {}), ...valueMap };
|
||||
|
||||
console.log(`✅ 우측 카테고리 매핑 로드 [${mappingKey}]:`, valueMap);
|
||||
console.log(`✅ 우측 카테고리 매핑 (컬럼명만) [${columnName}]:`, mappings[columnName]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`우측 카테고리 값 조회 실패 [${tableName}.${columnName}]:`, error);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`우측 카테고리 값 조회 실패 [${columnName}]:`, error);
|
||||
console.error(`테이블 ${tableName} 컬럼 정보 조회 실패:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -737,7 +772,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
};
|
||||
|
||||
loadRightCategoryMappings();
|
||||
}, [componentConfig.rightPanel?.tableName, isDesignMode]);
|
||||
}, [componentConfig.rightPanel?.tableName, componentConfig.rightPanel?.columns, isDesignMode]);
|
||||
|
||||
// 항목 펼치기/접기 토글
|
||||
const toggleExpand = useCallback((itemId: any) => {
|
||||
|
|
@ -2149,9 +2184,12 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
const format = colConfig?.format;
|
||||
const boldValue = colConfig?.bold ?? false;
|
||||
|
||||
// 숫자 포맷 적용
|
||||
let displayValue = String(value || "-");
|
||||
if (value !== null && value !== undefined && value !== "" && format) {
|
||||
// 🆕 카테고리 매핑 적용
|
||||
const formattedValue = formatCellValue(key, value, rightCategoryMappings);
|
||||
|
||||
// 숫자 포맷 적용 (카테고리가 아닌 경우만)
|
||||
let displayValue: React.ReactNode = formattedValue;
|
||||
if (typeof formattedValue === 'string' && value !== null && value !== undefined && value !== "" && format) {
|
||||
const numValue = typeof value === 'number' ? value : parseFloat(String(value));
|
||||
if (!isNaN(numValue)) {
|
||||
displayValue = numValue.toLocaleString('ko-KR', {
|
||||
|
|
@ -2175,7 +2213,6 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
)}
|
||||
<span
|
||||
className={`text-foreground text-sm ${boldValue ? 'font-semibold' : ''}`}
|
||||
title={displayValue}
|
||||
>
|
||||
{displayValue}
|
||||
</span>
|
||||
|
|
@ -2240,9 +2277,12 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
const colConfig = rightColumns?.find(c => c.name === key);
|
||||
const format = colConfig?.format;
|
||||
|
||||
// 숫자 포맷 적용
|
||||
let displayValue = String(value);
|
||||
if (value !== null && value !== undefined && value !== "" && format) {
|
||||
// 🆕 카테고리 매핑 적용
|
||||
const formattedValue = formatCellValue(key, value, rightCategoryMappings);
|
||||
|
||||
// 숫자 포맷 적용 (카테고리가 아닌 경우만)
|
||||
let displayValue: React.ReactNode = formattedValue;
|
||||
if (typeof formattedValue === 'string' && value !== null && value !== undefined && value !== "" && format) {
|
||||
const numValue = typeof value === 'number' ? value : parseFloat(String(value));
|
||||
if (!isNaN(numValue)) {
|
||||
displayValue = numValue.toLocaleString('ko-KR', {
|
||||
|
|
|
|||
|
|
@ -127,4 +127,3 @@ export interface SplitPanelLayout2Config {
|
|||
// 동작 설정
|
||||
autoLoad?: boolean; // 자동 데이터 로드
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -179,6 +179,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
config,
|
||||
className,
|
||||
style,
|
||||
formData: propFormData, // 🆕 부모에서 전달받은 formData
|
||||
onFormDataChange,
|
||||
componentConfig,
|
||||
onSelectedRowsChange,
|
||||
|
|
@ -1183,13 +1184,74 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
referenceTable: col.additionalJoinInfo!.referenceTable,
|
||||
}));
|
||||
|
||||
// console.log("🔍 [TableList] API 호출 시작", {
|
||||
// tableName: tableConfig.selectedTable,
|
||||
// page,
|
||||
// pageSize,
|
||||
// sortBy,
|
||||
// sortOrder,
|
||||
// });
|
||||
// 🎯 화면별 엔티티 표시 설정 수집
|
||||
const screenEntityConfigs: Record<string, any> = {};
|
||||
(tableConfig.columns || [])
|
||||
.filter((col) => col.entityDisplayConfig && col.entityDisplayConfig.displayColumns?.length > 0)
|
||||
.forEach((col) => {
|
||||
screenEntityConfigs[col.columnName] = {
|
||||
displayColumns: col.entityDisplayConfig!.displayColumns,
|
||||
separator: col.entityDisplayConfig!.separator || " - ",
|
||||
sourceTable: col.entityDisplayConfig!.sourceTable || tableConfig.selectedTable,
|
||||
joinTable: col.entityDisplayConfig!.joinTable,
|
||||
};
|
||||
});
|
||||
|
||||
console.log("🎯 [TableList] 화면별 엔티티 설정:", screenEntityConfigs);
|
||||
|
||||
// 🆕 제외 필터 처리 (다른 테이블에 이미 존재하는 데이터 제외)
|
||||
let excludeFilterParam: any = undefined;
|
||||
if (tableConfig.excludeFilter?.enabled) {
|
||||
const excludeConfig = tableConfig.excludeFilter;
|
||||
let filterValue: any = undefined;
|
||||
|
||||
// 필터 값 소스에 따라 값 가져오기 (우선순위: formData > URL > 분할패널)
|
||||
if (excludeConfig.filterColumn && excludeConfig.filterValueField) {
|
||||
const fieldName = excludeConfig.filterValueField;
|
||||
|
||||
// 1순위: props로 전달받은 formData에서 값 가져오기 (모달에서 사용)
|
||||
if (propFormData && propFormData[fieldName]) {
|
||||
filterValue = propFormData[fieldName];
|
||||
console.log("🔗 [TableList] formData에서 excludeFilter 값 가져오기:", {
|
||||
field: fieldName,
|
||||
value: filterValue,
|
||||
});
|
||||
}
|
||||
// 2순위: URL 파라미터에서 값 가져오기
|
||||
else if (typeof window !== "undefined") {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
filterValue = urlParams.get(fieldName);
|
||||
if (filterValue) {
|
||||
console.log("🔗 [TableList] URL에서 excludeFilter 값 가져오기:", {
|
||||
field: fieldName,
|
||||
value: filterValue,
|
||||
});
|
||||
}
|
||||
}
|
||||
// 3순위: 분할 패널 부모 데이터에서 값 가져오기
|
||||
if (!filterValue && splitPanelContext?.selectedLeftData) {
|
||||
filterValue = splitPanelContext.selectedLeftData[fieldName];
|
||||
if (filterValue) {
|
||||
console.log("🔗 [TableList] 분할패널에서 excludeFilter 값 가져오기:", {
|
||||
field: fieldName,
|
||||
value: filterValue,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (filterValue || !excludeConfig.filterColumn) {
|
||||
excludeFilterParam = {
|
||||
enabled: true,
|
||||
referenceTable: excludeConfig.referenceTable,
|
||||
referenceColumn: excludeConfig.referenceColumn,
|
||||
sourceColumn: excludeConfig.sourceColumn,
|
||||
filterColumn: excludeConfig.filterColumn,
|
||||
filterValue: filterValue,
|
||||
};
|
||||
console.log("🚫 [TableList] 제외 필터 적용:", excludeFilterParam);
|
||||
}
|
||||
}
|
||||
|
||||
// 🎯 항상 entityJoinApi 사용 (writer 컬럼 자동 조인 지원)
|
||||
response = await entityJoinApi.getTableDataWithJoins(tableConfig.selectedTable, {
|
||||
|
|
@ -1200,7 +1262,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
search: hasFilters ? filters : undefined,
|
||||
enableEntityJoin: true,
|
||||
additionalJoinColumns: entityJoinColumns.length > 0 ? entityJoinColumns : undefined,
|
||||
screenEntityConfigs: Object.keys(screenEntityConfigs).length > 0 ? screenEntityConfigs : undefined, // 🎯 화면별 엔티티 설정 전달
|
||||
dataFilter: tableConfig.dataFilter, // 🆕 데이터 필터 전달
|
||||
excludeFilter: excludeFilterParam, // 🆕 제외 필터 전달
|
||||
});
|
||||
|
||||
// 실제 데이터의 item_number만 추출하여 중복 확인
|
||||
|
|
@ -1756,33 +1820,46 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
|
||||
const formatCellValue = useCallback(
|
||||
(value: any, column: ColumnConfig, rowData?: Record<string, any>) => {
|
||||
if (value === null || value === undefined) return "-";
|
||||
|
||||
// 🎯 writer 컬럼 자동 변환: user_id -> user_name
|
||||
if (column.columnName === "writer" && rowData && rowData.writer_name) {
|
||||
return rowData.writer_name;
|
||||
}
|
||||
|
||||
// 🎯 엔티티 컬럼 표시 설정이 있는 경우
|
||||
// 🎯 엔티티 컬럼 표시 설정이 있는 경우 - value가 null이어도 rowData에서 조합 가능
|
||||
// 이 체크를 가장 먼저 수행 (null 체크보다 앞에)
|
||||
if (column.entityDisplayConfig && rowData) {
|
||||
// displayColumns 또는 selectedColumns 둘 다 체크
|
||||
const displayColumns = column.entityDisplayConfig.displayColumns || column.entityDisplayConfig.selectedColumns;
|
||||
const displayColumns = column.entityDisplayConfig.displayColumns || (column.entityDisplayConfig as any).selectedColumns;
|
||||
const separator = column.entityDisplayConfig.separator;
|
||||
|
||||
if (displayColumns && displayColumns.length > 0) {
|
||||
// 선택된 컬럼들의 값을 구분자로 조합
|
||||
const values = displayColumns
|
||||
.map((colName) => {
|
||||
const cellValue = rowData[colName];
|
||||
.map((colName: string) => {
|
||||
// 1. 먼저 직접 컬럼명으로 시도 (기본 테이블 컬럼인 경우)
|
||||
let cellValue = rowData[colName];
|
||||
|
||||
// 2. 없으면 ${sourceColumn}_${colName} 형식으로 시도 (조인 테이블 컬럼인 경우)
|
||||
if (cellValue === null || cellValue === undefined) {
|
||||
const joinedKey = `${column.columnName}_${colName}`;
|
||||
cellValue = rowData[joinedKey];
|
||||
}
|
||||
|
||||
if (cellValue === null || cellValue === undefined) return "";
|
||||
return String(cellValue);
|
||||
})
|
||||
.filter((v) => v !== ""); // 빈 값 제외
|
||||
.filter((v: string) => v !== ""); // 빈 값 제외
|
||||
|
||||
return values.join(separator || " - ");
|
||||
const result = values.join(separator || " - ");
|
||||
if (result) {
|
||||
return result; // 결과가 있으면 반환
|
||||
}
|
||||
// 결과가 비어있으면 아래로 계속 진행 (원래 값 사용)
|
||||
}
|
||||
}
|
||||
|
||||
// value가 null/undefined면 "-" 반환
|
||||
if (value === null || value === undefined) return "-";
|
||||
|
||||
// 🎯 writer 컬럼 자동 변환: user_id -> user_name
|
||||
if (column.columnName === "writer" && rowData && rowData.writer_name) {
|
||||
return rowData.writer_name;
|
||||
}
|
||||
|
||||
const meta = columnMeta[column.columnName];
|
||||
|
||||
// inputType 기반 포맷팅 (columnMeta에서 가져온 inputType 우선)
|
||||
|
|
@ -1906,12 +1983,16 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
return "-";
|
||||
}
|
||||
|
||||
// 숫자 타입 포맷팅
|
||||
// 숫자 타입 포맷팅 (천단위 구분자 설정 확인)
|
||||
if (inputType === "number" || inputType === "decimal") {
|
||||
if (value !== null && value !== undefined && value !== "") {
|
||||
const numValue = typeof value === "string" ? parseFloat(value) : value;
|
||||
if (!isNaN(numValue)) {
|
||||
return numValue.toLocaleString("ko-KR");
|
||||
// thousandSeparator가 false가 아닌 경우(기본값 true) 천단위 구분자 적용
|
||||
if (column.thousandSeparator !== false) {
|
||||
return numValue.toLocaleString("ko-KR");
|
||||
}
|
||||
return String(numValue);
|
||||
}
|
||||
}
|
||||
return String(value);
|
||||
|
|
@ -1922,7 +2003,11 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
if (value !== null && value !== undefined && value !== "") {
|
||||
const numValue = typeof value === "string" ? parseFloat(value) : value;
|
||||
if (!isNaN(numValue)) {
|
||||
return numValue.toLocaleString("ko-KR");
|
||||
// thousandSeparator가 false가 아닌 경우(기본값 true) 천단위 구분자 적용
|
||||
if (column.thousandSeparator !== false) {
|
||||
return numValue.toLocaleString("ko-KR");
|
||||
}
|
||||
return String(numValue);
|
||||
}
|
||||
}
|
||||
return String(value);
|
||||
|
|
@ -1939,10 +2024,15 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
}
|
||||
}
|
||||
return "-";
|
||||
case "number":
|
||||
return typeof value === "number" ? value.toLocaleString() : value;
|
||||
case "currency":
|
||||
return typeof value === "number" ? `₩${value.toLocaleString()}` : value;
|
||||
if (typeof value === "number") {
|
||||
// thousandSeparator가 false가 아닌 경우(기본값 true) 천단위 구분자 적용
|
||||
if (column.thousandSeparator !== false) {
|
||||
return `₩${value.toLocaleString()}`;
|
||||
}
|
||||
return `₩${value}`;
|
||||
}
|
||||
return value;
|
||||
case "boolean":
|
||||
return value ? "예" : "아니오";
|
||||
default:
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { Badge } from "@/components/ui/badge";
|
|||
import { TableListConfig, ColumnConfig } from "./types";
|
||||
import { entityJoinApi } from "@/lib/api/entityJoin";
|
||||
import { tableTypeApi } from "@/lib/api/screen";
|
||||
import { tableManagementApi } from "@/lib/api/tableManagement";
|
||||
import { Plus, Trash2, ArrowUp, ArrowDown, ChevronsUpDown, Check } from "lucide-react";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
|
|
@ -73,6 +74,12 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
|||
|
||||
const [loadingEntityJoins, setLoadingEntityJoins] = useState(false);
|
||||
|
||||
// 🆕 제외 필터용 참조 테이블 컬럼 목록
|
||||
const [referenceTableColumns, setReferenceTableColumns] = useState<
|
||||
Array<{ columnName: string; dataType: string; label?: string }>
|
||||
>([]);
|
||||
const [loadingReferenceColumns, setLoadingReferenceColumns] = useState(false);
|
||||
|
||||
// 🔄 외부에서 config가 변경될 때 내부 상태 동기화 (표의 페이지네이션 변경 감지)
|
||||
useEffect(() => {
|
||||
// console.log("🔄 TableListConfigPanel - 외부 config 변경 감지:", {
|
||||
|
|
@ -237,6 +244,42 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
|||
fetchEntityJoinColumns();
|
||||
}, [config.selectedTable, screenTableName]);
|
||||
|
||||
// 🆕 제외 필터용 참조 테이블 컬럼 가져오기
|
||||
useEffect(() => {
|
||||
const fetchReferenceColumns = async () => {
|
||||
const refTable = config.excludeFilter?.referenceTable;
|
||||
if (!refTable) {
|
||||
setReferenceTableColumns([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingReferenceColumns(true);
|
||||
try {
|
||||
console.log("🔗 참조 테이블 컬럼 정보 가져오기:", refTable);
|
||||
const result = await tableManagementApi.getColumnList(refTable);
|
||||
if (result.success && result.data) {
|
||||
// result.data는 { columns: [], total, page, size, totalPages } 형태
|
||||
const columns = result.data.columns || [];
|
||||
setReferenceTableColumns(
|
||||
columns.map((col: any) => ({
|
||||
columnName: col.columnName || col.column_name,
|
||||
dataType: col.dataType || col.data_type || "text",
|
||||
label: col.displayName || col.columnLabel || col.column_label || col.columnName || col.column_name,
|
||||
}))
|
||||
);
|
||||
console.log("✅ 참조 테이블 컬럼 로드 완료:", columns.length, "개");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ 참조 테이블 컬럼 조회 오류:", error);
|
||||
setReferenceTableColumns([]);
|
||||
} finally {
|
||||
setLoadingReferenceColumns(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchReferenceColumns();
|
||||
}, [config.excludeFilter?.referenceTable]);
|
||||
|
||||
// 🎯 엔티티 컬럼 자동 로드
|
||||
useEffect(() => {
|
||||
const entityColumns = config.columns?.filter((col) => col.isEntityJoin && col.entityDisplayConfig);
|
||||
|
|
@ -467,42 +510,22 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
|||
|
||||
// 🎯 엔티티 컬럼의 표시 컬럼 정보 로드
|
||||
const loadEntityDisplayConfig = async (column: ColumnConfig) => {
|
||||
if (!column.isEntityJoin || !column.entityJoinInfo) {
|
||||
return;
|
||||
}
|
||||
const configKey = `${column.columnName}`;
|
||||
|
||||
// 이미 로드된 경우 스킵
|
||||
if (entityDisplayConfigs[configKey]) return;
|
||||
|
||||
// entityDisplayConfig가 없으면 초기화
|
||||
if (!column.entityDisplayConfig) {
|
||||
// sourceTable을 결정: entityJoinInfo -> config.selectedTable -> screenTableName 순서
|
||||
const initialSourceTable = column.entityJoinInfo?.sourceTable || config.selectedTable || screenTableName;
|
||||
|
||||
if (!initialSourceTable) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedColumns = config.columns?.map((col) => {
|
||||
if (col.columnName === column.columnName) {
|
||||
return {
|
||||
...col,
|
||||
entityDisplayConfig: {
|
||||
displayColumns: [],
|
||||
separator: " - ",
|
||||
sourceTable: initialSourceTable,
|
||||
joinTable: "",
|
||||
},
|
||||
};
|
||||
}
|
||||
return col;
|
||||
});
|
||||
|
||||
if (updatedColumns) {
|
||||
handleChange("columns", updatedColumns);
|
||||
// 업데이트된 컬럼으로 다시 시도
|
||||
const updatedColumn = updatedColumns.find((col) => col.columnName === column.columnName);
|
||||
if (updatedColumn) {
|
||||
return loadEntityDisplayConfig(updatedColumn);
|
||||
}
|
||||
}
|
||||
if (!column.isEntityJoin) {
|
||||
// 엔티티 컬럼이 아니면 빈 상태로 설정하여 로딩 상태 해제
|
||||
setEntityDisplayConfigs((prev) => ({
|
||||
...prev,
|
||||
[configKey]: {
|
||||
sourceColumns: [],
|
||||
joinColumns: [],
|
||||
selectedColumns: [],
|
||||
separator: " - ",
|
||||
},
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -512,32 +535,56 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
|||
// 3. config.selectedTable
|
||||
// 4. screenTableName
|
||||
const sourceTable =
|
||||
column.entityDisplayConfig.sourceTable ||
|
||||
column.entityDisplayConfig?.sourceTable ||
|
||||
column.entityJoinInfo?.sourceTable ||
|
||||
config.selectedTable ||
|
||||
screenTableName;
|
||||
|
||||
let joinTable = column.entityDisplayConfig.joinTable;
|
||||
|
||||
// sourceTable이 여전히 비어있으면 에러
|
||||
// sourceTable이 비어있으면 빈 상태로 설정
|
||||
if (!sourceTable) {
|
||||
console.warn("⚠️ sourceTable을 찾을 수 없음:", column.columnName);
|
||||
setEntityDisplayConfigs((prev) => ({
|
||||
...prev,
|
||||
[configKey]: {
|
||||
sourceColumns: [],
|
||||
joinColumns: [],
|
||||
selectedColumns: column.entityDisplayConfig?.displayColumns || [],
|
||||
separator: column.entityDisplayConfig?.separator || " - ",
|
||||
},
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!joinTable && sourceTable) {
|
||||
// joinTable이 없으면 tableTypeApi로 조회해서 설정
|
||||
let joinTable = column.entityDisplayConfig?.joinTable;
|
||||
|
||||
// joinTable이 없으면 tableTypeApi로 조회해서 설정
|
||||
if (!joinTable) {
|
||||
try {
|
||||
console.log("🔍 tableTypeApi로 컬럼 정보 조회:", {
|
||||
tableName: sourceTable,
|
||||
columnName: column.columnName,
|
||||
});
|
||||
|
||||
const columnList = await tableTypeApi.getColumns(sourceTable);
|
||||
const columnInfo = columnList.find((col: any) => (col.column_name || col.columnName) === column.columnName);
|
||||
|
||||
console.log("🔍 컬럼 정보 조회 결과:", {
|
||||
columnInfo: columnInfo,
|
||||
referenceTable: columnInfo?.reference_table || columnInfo?.referenceTable,
|
||||
referenceColumn: columnInfo?.reference_column || columnInfo?.referenceColumn,
|
||||
});
|
||||
|
||||
if (columnInfo?.reference_table || columnInfo?.referenceTable) {
|
||||
joinTable = columnInfo.reference_table || columnInfo.referenceTable;
|
||||
console.log("✅ tableTypeApi에서 조인 테이블 정보 찾음:", joinTable);
|
||||
|
||||
// entityDisplayConfig 업데이트
|
||||
const updatedConfig = {
|
||||
...column.entityDisplayConfig,
|
||||
sourceTable: sourceTable,
|
||||
joinTable: joinTable,
|
||||
displayColumns: column.entityDisplayConfig?.displayColumns || [],
|
||||
separator: column.entityDisplayConfig?.separator || " - ",
|
||||
};
|
||||
|
||||
// 컬럼 설정 업데이트
|
||||
|
|
@ -553,74 +600,27 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
|||
}
|
||||
} catch (error) {
|
||||
console.error("tableTypeApi 컬럼 정보 조회 실패:", error);
|
||||
console.log("❌ 조회 실패 상세:", { sourceTable, columnName: column.columnName });
|
||||
}
|
||||
} else if (!joinTable) {
|
||||
console.warn("⚠️ sourceTable이 없어서 joinTable 조회 불가:", column.columnName);
|
||||
}
|
||||
|
||||
console.log("🔍 최종 추출한 값:", { sourceTable, joinTable });
|
||||
const configKey = `${column.columnName}`;
|
||||
|
||||
// 이미 로드된 경우 스킵
|
||||
if (entityDisplayConfigs[configKey]) return;
|
||||
|
||||
// joinTable이 비어있으면 tableTypeApi로 컬럼 정보를 다시 가져와서 referenceTable 정보를 찾기
|
||||
let actualJoinTable = joinTable;
|
||||
if (!actualJoinTable && sourceTable) {
|
||||
try {
|
||||
console.log("🔍 tableTypeApi로 컬럼 정보 다시 조회:", {
|
||||
tableName: sourceTable,
|
||||
columnName: column.columnName,
|
||||
});
|
||||
|
||||
const columnList = await tableTypeApi.getColumns(sourceTable);
|
||||
const columnInfo = columnList.find((col: any) => (col.column_name || col.columnName) === column.columnName);
|
||||
|
||||
console.log("🔍 컬럼 정보 조회 결과:", {
|
||||
columnInfo: columnInfo,
|
||||
referenceTable: columnInfo?.reference_table || columnInfo?.referenceTable,
|
||||
referenceColumn: columnInfo?.reference_column || columnInfo?.referenceColumn,
|
||||
});
|
||||
|
||||
if (columnInfo?.reference_table || columnInfo?.referenceTable) {
|
||||
actualJoinTable = columnInfo.reference_table || columnInfo.referenceTable;
|
||||
console.log("✅ tableTypeApi에서 조인 테이블 정보 찾음:", actualJoinTable);
|
||||
|
||||
// entityDisplayConfig 업데이트
|
||||
const updatedConfig = {
|
||||
...column.entityDisplayConfig,
|
||||
joinTable: actualJoinTable,
|
||||
};
|
||||
|
||||
// 컬럼 설정 업데이트
|
||||
const updatedColumns = config.columns?.map((col) =>
|
||||
col.columnName === column.columnName ? { ...col, entityDisplayConfig: updatedConfig } : col,
|
||||
);
|
||||
|
||||
if (updatedColumns) {
|
||||
handleChange("columns", updatedColumns);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("tableTypeApi 컬럼 정보 조회 실패:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// sourceTable과 joinTable이 모두 있어야 로드
|
||||
if (!sourceTable || !actualJoinTable) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 기본 테이블과 조인 테이블의 컬럼 정보를 병렬로 로드
|
||||
const [sourceResult, joinResult] = await Promise.all([
|
||||
entityJoinApi.getReferenceTableColumns(sourceTable),
|
||||
entityJoinApi.getReferenceTableColumns(actualJoinTable),
|
||||
]);
|
||||
|
||||
// 기본 테이블 컬럼 정보는 항상 로드
|
||||
const sourceResult = await entityJoinApi.getReferenceTableColumns(sourceTable);
|
||||
const sourceColumns = sourceResult.columns || [];
|
||||
const joinColumns = joinResult.columns || [];
|
||||
|
||||
// joinTable이 있으면 조인 테이블 컬럼도 로드
|
||||
let joinColumns: Array<{ columnName: string; displayName: string; dataType: string }> = [];
|
||||
if (joinTable) {
|
||||
try {
|
||||
const joinResult = await entityJoinApi.getReferenceTableColumns(joinTable);
|
||||
joinColumns = joinResult.columns || [];
|
||||
} catch (joinError) {
|
||||
console.warn("⚠️ 조인 테이블 컬럼 로드 실패:", joinTable, joinError);
|
||||
// 조인 테이블 로드 실패해도 소스 테이블 컬럼은 표시
|
||||
}
|
||||
}
|
||||
|
||||
setEntityDisplayConfigs((prev) => ({
|
||||
...prev,
|
||||
|
|
@ -633,6 +633,16 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
|||
}));
|
||||
} catch (error) {
|
||||
console.error("엔티티 표시 컬럼 정보 로드 실패:", error);
|
||||
// 에러 발생 시에도 빈 상태로 설정하여 로딩 상태 해제
|
||||
setEntityDisplayConfigs((prev) => ({
|
||||
...prev,
|
||||
[configKey]: {
|
||||
sourceColumns: [],
|
||||
joinColumns: [],
|
||||
selectedColumns: column.entityDisplayConfig?.displayColumns || [],
|
||||
separator: column.entityDisplayConfig?.separator || " - ",
|
||||
},
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -873,76 +883,95 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
|||
{/* 표시 컬럼 선택 (다중 선택) */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">표시할 컬럼 선택</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-6 w-full justify-between text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
>
|
||||
{entityDisplayConfigs[column.columnName].selectedColumns.length > 0
|
||||
? `${entityDisplayConfigs[column.columnName].selectedColumns.length}개 선택됨`
|
||||
: "컬럼 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="컬럼 검색..." className="text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-xs">컬럼을 찾을 수 없습니다.</CommandEmpty>
|
||||
{entityDisplayConfigs[column.columnName].sourceColumns.length > 0 && (
|
||||
<CommandGroup heading={`기본: ${column.entityDisplayConfig?.sourceTable}`}>
|
||||
{entityDisplayConfigs[column.columnName].sourceColumns.map((col) => (
|
||||
<CommandItem
|
||||
key={`source-${col.columnName}`}
|
||||
onSelect={() => toggleEntityDisplayColumn(column.columnName, col.columnName)}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
entityDisplayConfigs[column.columnName].selectedColumns.includes(
|
||||
col.columnName,
|
||||
)
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{col.displayName}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
{entityDisplayConfigs[column.columnName].joinColumns.length > 0 && (
|
||||
<CommandGroup heading={`조인: ${column.entityDisplayConfig?.joinTable}`}>
|
||||
{entityDisplayConfigs[column.columnName].joinColumns.map((col) => (
|
||||
<CommandItem
|
||||
key={`join-${col.columnName}`}
|
||||
onSelect={() => toggleEntityDisplayColumn(column.columnName, col.columnName)}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
entityDisplayConfigs[column.columnName].selectedColumns.includes(
|
||||
col.columnName,
|
||||
)
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{col.displayName}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{entityDisplayConfigs[column.columnName].sourceColumns.length === 0 &&
|
||||
entityDisplayConfigs[column.columnName].joinColumns.length === 0 ? (
|
||||
<div className="py-2 text-center text-xs text-gray-400">
|
||||
표시 가능한 컬럼이 없습니다.
|
||||
{!column.entityDisplayConfig?.joinTable && (
|
||||
<p className="mt-1 text-[10px]">
|
||||
테이블 타입 관리에서 참조 테이블을 설정하면 더 많은 컬럼을 선택할 수 있습니다.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-6 w-full justify-between text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
>
|
||||
{entityDisplayConfigs[column.columnName].selectedColumns.length > 0
|
||||
? `${entityDisplayConfigs[column.columnName].selectedColumns.length}개 선택됨`
|
||||
: "컬럼 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="컬럼 검색..." className="text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-xs">컬럼을 찾을 수 없습니다.</CommandEmpty>
|
||||
{entityDisplayConfigs[column.columnName].sourceColumns.length > 0 && (
|
||||
<CommandGroup heading={`기본 테이블: ${column.entityDisplayConfig?.sourceTable || config.selectedTable || screenTableName}`}>
|
||||
{entityDisplayConfigs[column.columnName].sourceColumns.map((col) => (
|
||||
<CommandItem
|
||||
key={`source-${col.columnName}`}
|
||||
onSelect={() => toggleEntityDisplayColumn(column.columnName, col.columnName)}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
entityDisplayConfigs[column.columnName].selectedColumns.includes(
|
||||
col.columnName,
|
||||
)
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{col.displayName}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
{entityDisplayConfigs[column.columnName].joinColumns.length > 0 && (
|
||||
<CommandGroup heading={`참조 테이블: ${column.entityDisplayConfig?.joinTable}`}>
|
||||
{entityDisplayConfigs[column.columnName].joinColumns.map((col) => (
|
||||
<CommandItem
|
||||
key={`join-${col.columnName}`}
|
||||
onSelect={() => toggleEntityDisplayColumn(column.columnName, col.columnName)}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
entityDisplayConfigs[column.columnName].selectedColumns.includes(
|
||||
col.columnName,
|
||||
)
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{col.displayName}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 참조 테이블 미설정 안내 */}
|
||||
{!column.entityDisplayConfig?.joinTable && entityDisplayConfigs[column.columnName].sourceColumns.length > 0 && (
|
||||
<div className="rounded bg-blue-50 p-2 text-[10px] text-blue-600">
|
||||
현재 기본 테이블 컬럼만 표시됩니다. 테이블 타입 관리에서 참조 테이블을 설정하면 조인된 테이블의 컬럼도 선택할 수 있습니다.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 선택된 컬럼 미리보기 */}
|
||||
{entityDisplayConfigs[column.columnName].selectedColumns.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
|
|
@ -1074,86 +1103,111 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
|||
|
||||
{/* 간결한 리스트 형식 컬럼 설정 */}
|
||||
<div className="space-y-1">
|
||||
{config.columns?.map((column, index) => (
|
||||
<div
|
||||
key={column.columnName}
|
||||
className="hover:bg-muted/30 flex h-6 items-center justify-between rounded border px-2"
|
||||
>
|
||||
{/* 컬럼명 */}
|
||||
<span className="flex-1 truncate text-xs" style={{ fontSize: "12px" }}>
|
||||
{availableColumns.find((col) => col.columnName === column.columnName)?.label ||
|
||||
column.displayName ||
|
||||
column.columnName}
|
||||
</span>
|
||||
{config.columns?.map((column, index) => {
|
||||
// 해당 컬럼의 input_type 확인
|
||||
const columnInfo = availableColumns.find((col) => col.columnName === column.columnName);
|
||||
const isNumberType = columnInfo?.input_type === "number" || columnInfo?.input_type === "decimal";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={column.columnName}
|
||||
className="hover:bg-muted/30 flex items-center justify-between rounded border px-2 py-1"
|
||||
style={{ minHeight: "28px" }}
|
||||
>
|
||||
<div className="flex flex-1 flex-col gap-0.5 overflow-hidden">
|
||||
{/* 컬럼명 */}
|
||||
<span className="truncate text-xs" style={{ fontSize: "12px" }}>
|
||||
{columnInfo?.label || column.displayName || column.columnName}
|
||||
</span>
|
||||
|
||||
{/* 숫자 타입인 경우 천단위 구분자 설정 */}
|
||||
{isNumberType && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Checkbox
|
||||
id={`thousand-sep-${column.columnName}`}
|
||||
checked={column.thousandSeparator !== false}
|
||||
onCheckedChange={(checked) => {
|
||||
updateColumn(column.columnName, { thousandSeparator: checked as boolean });
|
||||
}}
|
||||
className="h-3 w-3"
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`thousand-sep-${column.columnName}`}
|
||||
className="text-[10px] text-muted-foreground cursor-pointer"
|
||||
>
|
||||
천단위 구분자
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 필터 체크박스 + 순서 변경 + 삭제 버튼 */}
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Checkbox
|
||||
checked={config.filter?.filters?.some((f) => f.columnName === column.columnName) || false}
|
||||
onCheckedChange={(checked) => {
|
||||
const currentFilters = config.filter?.filters || [];
|
||||
const columnLabel =
|
||||
availableColumns.find((col) => col.columnName === column.columnName)?.label ||
|
||||
column.displayName ||
|
||||
column.columnName;
|
||||
{/* 필터 체크박스 + 순서 변경 + 삭제 버튼 */}
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Checkbox
|
||||
checked={config.filter?.filters?.some((f) => f.columnName === column.columnName) || false}
|
||||
onCheckedChange={(checked) => {
|
||||
const currentFilters = config.filter?.filters || [];
|
||||
const columnLabel =
|
||||
columnInfo?.label || column.displayName || column.columnName;
|
||||
|
||||
if (checked) {
|
||||
// 필터 추가
|
||||
handleChange("filter", {
|
||||
...config.filter,
|
||||
enabled: true,
|
||||
filters: [
|
||||
...currentFilters,
|
||||
{
|
||||
columnName: column.columnName,
|
||||
label: columnLabel,
|
||||
type: "text",
|
||||
},
|
||||
],
|
||||
});
|
||||
} else {
|
||||
// 필터 제거
|
||||
handleChange("filter", {
|
||||
...config.filter,
|
||||
filters: currentFilters.filter((f) => f.columnName !== column.columnName),
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="h-3 w-3"
|
||||
/>
|
||||
if (checked) {
|
||||
// 필터 추가
|
||||
handleChange("filter", {
|
||||
...config.filter,
|
||||
enabled: true,
|
||||
filters: [
|
||||
...currentFilters,
|
||||
{
|
||||
columnName: column.columnName,
|
||||
label: columnLabel,
|
||||
type: "text",
|
||||
},
|
||||
],
|
||||
});
|
||||
} else {
|
||||
// 필터 제거
|
||||
handleChange("filter", {
|
||||
...config.filter,
|
||||
filters: currentFilters.filter((f) => f.columnName !== column.columnName),
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="h-3 w-3"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 순서 변경 + 삭제 버튼 */}
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => moveColumn(column.columnName, "up")}
|
||||
disabled={index === 0}
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<ArrowUp className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => moveColumn(column.columnName, "down")}
|
||||
disabled={index === (config.columns?.length || 0) - 1}
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<ArrowDown className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeColumn(column.columnName)}
|
||||
className="h-6 w-6 p-0 text-red-500 hover:text-red-600"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 순서 변경 + 삭제 버튼 */}
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => moveColumn(column.columnName, "up")}
|
||||
disabled={index === 0}
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<ArrowUp className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => moveColumn(column.columnName, "down")}
|
||||
disabled={index === (config.columns?.length || 0) - 1}
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<ArrowDown className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeColumn(column.columnName)}
|
||||
className="h-6 w-6 p-0 text-red-500 hover:text-red-600"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -1322,6 +1376,298 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
|||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 🆕 제외 필터 설정 (다른 테이블에 이미 존재하는 데이터 제외) */}
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">제외 필터</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
다른 테이블에 이미 존재하는 데이터를 목록에서 제외합니다
|
||||
</p>
|
||||
</div>
|
||||
<hr className="border-border" />
|
||||
|
||||
{/* 제외 필터 활성화 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="excludeFilter-enabled"
|
||||
checked={config.excludeFilter?.enabled || false}
|
||||
onCheckedChange={(checked) => {
|
||||
handleChange("excludeFilter", {
|
||||
...config.excludeFilter,
|
||||
enabled: checked as boolean,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="excludeFilter-enabled" className="text-xs">
|
||||
제외 필터 활성화
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{config.excludeFilter?.enabled && (
|
||||
<div className="space-y-3 rounded border p-3">
|
||||
{/* 참조 테이블 선택 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium">참조 테이블 (매핑 테이블)</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="h-8 w-full justify-between text-xs"
|
||||
>
|
||||
{config.excludeFilter?.referenceTable || "테이블 선택..."}
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[300px] p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="테이블 검색..." className="h-8 text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-xs py-2">테이블을 찾을 수 없습니다</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{availableTables.map((table) => (
|
||||
<CommandItem
|
||||
key={table.tableName}
|
||||
value={table.tableName}
|
||||
onSelect={() => {
|
||||
handleChange("excludeFilter", {
|
||||
...config.excludeFilter,
|
||||
referenceTable: table.tableName,
|
||||
referenceColumn: undefined,
|
||||
sourceColumn: undefined,
|
||||
filterColumn: undefined,
|
||||
filterValueField: undefined,
|
||||
});
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
config.excludeFilter?.referenceTable === table.tableName ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{table.displayName || table.tableName}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{config.excludeFilter?.referenceTable && (
|
||||
<>
|
||||
{/* 비교 컬럼 설정 - 한 줄에 두 개 */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{/* 참조 컬럼 (매핑 테이블) */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">비교 컬럼 (매핑)</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
disabled={loadingReferenceColumns}
|
||||
className="h-8 w-full justify-between text-xs"
|
||||
>
|
||||
{loadingReferenceColumns
|
||||
? "..."
|
||||
: config.excludeFilter?.referenceColumn || "선택"}
|
||||
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="검색..." className="h-8 text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-xs py-2">없음</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{referenceTableColumns.map((col) => (
|
||||
<CommandItem
|
||||
key={col.columnName}
|
||||
value={col.columnName}
|
||||
onSelect={() => {
|
||||
handleChange("excludeFilter", {
|
||||
...config.excludeFilter,
|
||||
referenceColumn: col.columnName,
|
||||
});
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
config.excludeFilter?.referenceColumn === col.columnName ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{col.label || col.columnName}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* 소스 컬럼 (현재 테이블) */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">비교 컬럼 (현재)</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="h-8 w-full justify-between text-xs"
|
||||
>
|
||||
{config.excludeFilter?.sourceColumn || "선택"}
|
||||
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="검색..." className="h-8 text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-xs py-2">없음</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{availableColumns.map((col) => (
|
||||
<CommandItem
|
||||
key={col.columnName}
|
||||
value={col.columnName}
|
||||
onSelect={() => {
|
||||
handleChange("excludeFilter", {
|
||||
...config.excludeFilter,
|
||||
sourceColumn: col.columnName,
|
||||
});
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
config.excludeFilter?.sourceColumn === col.columnName ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{col.label || col.columnName}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 조건 필터 - 특정 조건의 데이터만 제외 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">조건 필터 (선택사항)</Label>
|
||||
<p className="text-[10px] text-muted-foreground mb-1">
|
||||
특정 조건의 데이터만 제외하려면 설정하세요 (예: 특정 거래처의 품목만)
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{/* 필터 컬럼 (매핑 테이블) */}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
disabled={loadingReferenceColumns}
|
||||
className="h-8 w-full justify-between text-xs"
|
||||
>
|
||||
{loadingReferenceColumns
|
||||
? "..."
|
||||
: config.excludeFilter?.filterColumn
|
||||
? `매핑: ${config.excludeFilter.filterColumn}`
|
||||
: "매핑 테이블 컬럼"}
|
||||
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="검색..." className="h-8 text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-xs py-2">없음</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value=""
|
||||
onSelect={() => {
|
||||
handleChange("excludeFilter", {
|
||||
...config.excludeFilter,
|
||||
filterColumn: undefined,
|
||||
filterValueField: undefined,
|
||||
});
|
||||
}}
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
<Check className={cn("mr-2 h-3 w-3", !config.excludeFilter?.filterColumn ? "opacity-100" : "opacity-0")} />
|
||||
사용 안함
|
||||
</CommandItem>
|
||||
{referenceTableColumns.map((col) => (
|
||||
<CommandItem
|
||||
key={col.columnName}
|
||||
value={col.columnName}
|
||||
onSelect={() => {
|
||||
// 필터 컬럼 선택 시 같은 이름의 필드를 자동으로 설정
|
||||
handleChange("excludeFilter", {
|
||||
...config.excludeFilter,
|
||||
filterColumn: col.columnName,
|
||||
filterValueField: col.columnName, // 같은 이름으로 자동 설정
|
||||
filterValueSource: "url",
|
||||
});
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
config.excludeFilter?.filterColumn === col.columnName ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{col.label || col.columnName}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* 필터 값 필드명 (부모 화면에서 전달받는 필드) */}
|
||||
<Input
|
||||
placeholder="예: customer_code"
|
||||
value={config.excludeFilter?.filterValueField || ""}
|
||||
onChange={(e) => {
|
||||
handleChange("excludeFilter", {
|
||||
...config.excludeFilter,
|
||||
filterValueField: e.target.value,
|
||||
});
|
||||
}}
|
||||
disabled={!config.excludeFilter?.filterColumn}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 설정 요약 */}
|
||||
{config.excludeFilter?.referenceTable && config.excludeFilter?.referenceColumn && config.excludeFilter?.sourceColumn && (
|
||||
<div className="rounded bg-muted/50 p-2 text-[10px] text-muted-foreground">
|
||||
<strong>설정 요약:</strong> {config.selectedTable || screenTableName}.{config.excludeFilter.sourceColumn} 가
|
||||
{" "}{config.excludeFilter.referenceTable}.{config.excludeFilter.referenceColumn} 에
|
||||
{config.excludeFilter.filterColumn && config.excludeFilter.filterValueField && (
|
||||
<> ({config.excludeFilter.filterColumn}=URL의 {config.excludeFilter.filterValueField}일 때)</>
|
||||
)}
|
||||
{" "}이미 있으면 제외
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -59,6 +59,9 @@ export interface ColumnConfig {
|
|||
isEntityJoin?: boolean; // Entity 조인된 컬럼인지 여부
|
||||
entityJoinInfo?: EntityJoinInfo; // Entity 조인 상세 정보
|
||||
|
||||
// 숫자 포맷팅 설정
|
||||
thousandSeparator?: boolean; // 천단위 구분자 사용 여부 (기본: true)
|
||||
|
||||
// 🎯 엔티티 컬럼 표시 설정 (화면별 동적 설정)
|
||||
entityDisplayConfig?: {
|
||||
displayColumns: string[]; // 표시할 컬럼들 (기본 테이블 + 조인 테이블)
|
||||
|
|
@ -182,6 +185,21 @@ export interface LinkedFilterConfig {
|
|||
enabled?: boolean; // 활성화 여부 (기본: true)
|
||||
}
|
||||
|
||||
/**
|
||||
* 제외 필터 설정
|
||||
* 다른 테이블에 이미 존재하는 데이터를 제외하고 표시
|
||||
* 예: 거래처에 이미 등록된 품목을 품목 선택 모달에서 제외
|
||||
*/
|
||||
export interface ExcludeFilterConfig {
|
||||
enabled: boolean; // 제외 필터 활성화 여부
|
||||
referenceTable: string; // 참조 테이블 (예: customer_item_mapping)
|
||||
referenceColumn: string; // 참조 테이블의 비교 컬럼 (예: item_id)
|
||||
sourceColumn: string; // 현재 테이블의 비교 컬럼 (예: item_number)
|
||||
filterColumn?: string; // 참조 테이블의 필터 컬럼 (예: customer_id)
|
||||
filterValueSource?: "url" | "formData" | "parentData"; // 필터 값 소스 (기본: url)
|
||||
filterValueField?: string; // 필터 값 필드명 (예: customer_code)
|
||||
}
|
||||
|
||||
/**
|
||||
* TableList 컴포넌트 설정 타입
|
||||
*/
|
||||
|
|
@ -246,6 +264,9 @@ export interface TableListConfig extends ComponentConfig {
|
|||
// 🆕 연결된 필터 (다른 컴포넌트 값으로 필터링)
|
||||
linkedFilters?: LinkedFilterConfig[];
|
||||
|
||||
// 🆕 제외 필터 (다른 테이블에 이미 존재하는 데이터 제외)
|
||||
excludeFilter?: ExcludeFilterConfig;
|
||||
|
||||
// 이벤트 핸들러
|
||||
onRowClick?: (row: any) => void;
|
||||
onRowDoubleClick?: (row: any) => void;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import React, { useState, useEffect, useRef, useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Settings, Filter, Layers, X, Check, ChevronsUpDown } from "lucide-react";
|
||||
|
|
@ -41,6 +41,7 @@ interface TableSearchWidgetProps {
|
|||
showTableSelector?: boolean; // 테이블 선택 드롭다운 표시 여부
|
||||
filterMode?: "dynamic" | "preset"; // 필터 모드
|
||||
presetFilters?: PresetFilter[]; // 고정 필터 목록
|
||||
targetPanelPosition?: "left" | "right" | "auto"; // 분할 패널에서 대상 패널 위치 (기본: "left")
|
||||
};
|
||||
};
|
||||
screenId?: number; // 화면 ID
|
||||
|
|
@ -82,19 +83,90 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
|||
const showTableSelector = component.componentConfig?.showTableSelector ?? true;
|
||||
const filterMode = component.componentConfig?.filterMode ?? "dynamic";
|
||||
const presetFilters = component.componentConfig?.presetFilters ?? [];
|
||||
const targetPanelPosition = component.componentConfig?.targetPanelPosition ?? "left"; // 기본값: 좌측 패널
|
||||
|
||||
// Map을 배열로 변환
|
||||
const tableList = Array.from(registeredTables.values());
|
||||
const currentTable = selectedTableId ? getTable(selectedTableId) : undefined;
|
||||
|
||||
// 첫 번째 테이블 자동 선택
|
||||
useEffect(() => {
|
||||
const tables = Array.from(registeredTables.values());
|
||||
|
||||
if (autoSelectFirstTable && tables.length > 0 && !selectedTableId) {
|
||||
setSelectedTableId(tables[0].tableId);
|
||||
const allTableList = Array.from(registeredTables.values());
|
||||
|
||||
// 대상 패널 위치에 따라 테이블 필터링 (tableId 패턴 기반)
|
||||
const tableList = useMemo(() => {
|
||||
// "auto"면 모든 테이블 반환
|
||||
if (targetPanelPosition === "auto") {
|
||||
return allTableList;
|
||||
}
|
||||
}, [registeredTables, selectedTableId, autoSelectFirstTable, setSelectedTableId]);
|
||||
|
||||
// 테이블 ID 패턴으로 필터링
|
||||
// card-display-XXX: 좌측 패널 (카드 디스플레이)
|
||||
// datatable-XXX, table-list-XXX: 우측 패널 (테이블 리스트)
|
||||
const filteredTables = allTableList.filter(table => {
|
||||
const tableId = table.tableId.toLowerCase();
|
||||
|
||||
if (targetPanelPosition === "left") {
|
||||
// 좌측 패널 대상: card-display만
|
||||
return tableId.includes("card-display") || tableId.includes("card");
|
||||
} else if (targetPanelPosition === "right") {
|
||||
// 우측 패널 대상: datatable, table-list 등 (card-display 제외)
|
||||
const isCardDisplay = tableId.includes("card-display") || tableId.includes("card");
|
||||
return !isCardDisplay;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// 필터링된 결과가 없으면 모든 테이블 반환 (폴백)
|
||||
if (filteredTables.length === 0) {
|
||||
console.log("🔍 [TableSearchWidget] 대상 패널에 테이블 없음, 전체 테이블 사용:", {
|
||||
targetPanelPosition,
|
||||
allTablesCount: allTableList.length,
|
||||
allTableIds: allTableList.map(t => t.tableId),
|
||||
});
|
||||
return allTableList;
|
||||
}
|
||||
|
||||
console.log("🔍 [TableSearchWidget] 테이블 필터링:", {
|
||||
targetPanelPosition,
|
||||
allTablesCount: allTableList.length,
|
||||
filteredCount: filteredTables.length,
|
||||
filteredTableIds: filteredTables.map(t => t.tableId),
|
||||
});
|
||||
|
||||
return filteredTables;
|
||||
}, [allTableList, targetPanelPosition]);
|
||||
|
||||
// currentTable은 tableList(필터링된 목록)에서 가져와야 함
|
||||
const currentTable = useMemo(() => {
|
||||
if (!selectedTableId) return undefined;
|
||||
|
||||
// 먼저 tableList(필터링된 목록)에서 찾기
|
||||
const tableFromList = tableList.find(t => t.tableId === selectedTableId);
|
||||
if (tableFromList) {
|
||||
return tableFromList;
|
||||
}
|
||||
|
||||
// tableList에 없으면 전체에서 찾기 (폴백)
|
||||
return getTable(selectedTableId);
|
||||
}, [selectedTableId, tableList, getTable]);
|
||||
|
||||
// 대상 패널의 첫 번째 테이블 자동 선택
|
||||
useEffect(() => {
|
||||
if (!autoSelectFirstTable || tableList.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 현재 선택된 테이블이 대상 패널에 있는지 확인
|
||||
const isCurrentTableInTarget = selectedTableId && tableList.some(t => t.tableId === selectedTableId);
|
||||
|
||||
// 현재 선택된 테이블이 대상 패널에 없으면 대상 패널의 첫 번째 테이블 선택
|
||||
if (!selectedTableId || !isCurrentTableInTarget) {
|
||||
const targetTable = tableList[0];
|
||||
console.log("🔍 [TableSearchWidget] 대상 패널 테이블 자동 선택:", {
|
||||
targetPanelPosition,
|
||||
selectedTableId: targetTable.tableId,
|
||||
tableName: targetTable.tableName,
|
||||
});
|
||||
setSelectedTableId(targetTable.tableId);
|
||||
}
|
||||
}, [tableList, selectedTableId, autoSelectFirstTable, setSelectedTableId, targetPanelPosition]);
|
||||
|
||||
// 현재 테이블의 저장된 필터 불러오기 (동적 모드) 또는 고정 필터 적용 (고정 모드)
|
||||
useEffect(() => {
|
||||
|
|
@ -302,6 +374,12 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
|||
return true;
|
||||
});
|
||||
|
||||
console.log("🔍 [TableSearchWidget] 필터 적용:", {
|
||||
currentTableId: currentTable?.tableId,
|
||||
currentTableName: currentTable?.tableName,
|
||||
filtersCount: filtersWithValues.length,
|
||||
filtersWithValues,
|
||||
});
|
||||
currentTable?.onFilterChange(filtersWithValues);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -76,12 +76,16 @@ export function TableSearchWidgetConfigPanel({
|
|||
const [localPresetFilters, setLocalPresetFilters] = useState<PresetFilter[]>(
|
||||
currentConfig.presetFilters ?? []
|
||||
);
|
||||
const [localTargetPanelPosition, setLocalTargetPanelPosition] = useState<"left" | "right" | "auto">(
|
||||
currentConfig.targetPanelPosition ?? "left"
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalAutoSelect(currentConfig.autoSelectFirstTable ?? true);
|
||||
setLocalShowSelector(currentConfig.showTableSelector ?? true);
|
||||
setLocalFilterMode(currentConfig.filterMode ?? "dynamic");
|
||||
setLocalPresetFilters(currentConfig.presetFilters ?? []);
|
||||
setLocalTargetPanelPosition(currentConfig.targetPanelPosition ?? "left");
|
||||
}, [currentConfig]);
|
||||
|
||||
// 설정 업데이트 헬퍼
|
||||
|
|
@ -164,6 +168,40 @@ export function TableSearchWidgetConfigPanel({
|
|||
</Label>
|
||||
</div>
|
||||
|
||||
{/* 대상 패널 위치 (분할 패널용) */}
|
||||
<div className="space-y-2 border-t pt-4">
|
||||
<Label className="text-xs sm:text-sm font-medium">대상 패널 위치 (분할 패널)</Label>
|
||||
<p className="text-[10px] text-muted-foreground mb-2">
|
||||
분할 패널이 있는 화면에서 검색 필터가 어떤 패널의 컴포넌트를 대상으로 할지 선택합니다.
|
||||
</p>
|
||||
<RadioGroup
|
||||
value={localTargetPanelPosition}
|
||||
onValueChange={(value: "left" | "right" | "auto") => {
|
||||
setLocalTargetPanelPosition(value);
|
||||
handleUpdate("targetPanelPosition", value);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="left" id="target-left" />
|
||||
<Label htmlFor="target-left" className="text-xs sm:text-sm cursor-pointer font-normal">
|
||||
좌측 패널 (카드 디스플레이 등)
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="right" id="target-right" />
|
||||
<Label htmlFor="target-right" className="text-xs sm:text-sm cursor-pointer font-normal">
|
||||
우측 패널 (테이블 리스트 등)
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="auto" id="target-auto" />
|
||||
<Label htmlFor="target-auto" className="text-xs sm:text-sm cursor-pointer font-normal">
|
||||
자동 (모든 테이블 대상)
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
{/* 필터 모드 선택 */}
|
||||
<div className="space-y-2 border-t pt-4">
|
||||
<Label className="text-xs sm:text-sm font-medium">필터 모드</Label>
|
||||
|
|
|
|||
|
|
@ -1236,8 +1236,13 @@ export class ButtonActionExecutor {
|
|||
} else {
|
||||
console.log("🔄 테이블 데이터 삭제 완료, 테이블 새로고침 호출");
|
||||
context.onRefresh?.(); // 테이블 새로고침
|
||||
|
||||
// 🆕 분할 패널 등 전역 테이블 새로고침 이벤트 발생
|
||||
window.dispatchEvent(new CustomEvent("refreshTable"));
|
||||
console.log("🔄 refreshTable 전역 이벤트 발생");
|
||||
}
|
||||
|
||||
toast.success(config.successMessage || `${dataToDelete.length}개 항목이 삭제되었습니다.`);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -1258,6 +1263,12 @@ export class ButtonActionExecutor {
|
|||
}
|
||||
|
||||
context.onRefresh?.();
|
||||
|
||||
// 🆕 분할 패널 등 전역 테이블 새로고침 이벤트 발생
|
||||
window.dispatchEvent(new CustomEvent("refreshTable"));
|
||||
console.log("🔄 refreshTable 전역 이벤트 발생 (단일 삭제)");
|
||||
|
||||
toast.success(config.successMessage || "삭제되었습니다.");
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("삭제 오류:", error);
|
||||
|
|
@ -1536,6 +1547,13 @@ export class ButtonActionExecutor {
|
|||
}
|
||||
}
|
||||
|
||||
// 🆕 부모 화면의 선택된 데이터 가져오기 (excludeFilter에서 사용)
|
||||
const parentData = dataRegistry[dataSourceId]?.[0]?.originalData || dataRegistry[dataSourceId]?.[0] || {};
|
||||
console.log("📦 [openModalWithData] 부모 데이터 전달:", {
|
||||
dataSourceId,
|
||||
parentData,
|
||||
});
|
||||
|
||||
// 🆕 전역 모달 상태 업데이트를 위한 이벤트 발생 (URL 파라미터 포함)
|
||||
const modalEvent = new CustomEvent("openScreenModal", {
|
||||
detail: {
|
||||
|
|
@ -1544,6 +1562,7 @@ export class ButtonActionExecutor {
|
|||
description: description,
|
||||
size: config.modalSize || "lg", // 데이터 입력 화면은 기본 large
|
||||
urlParams: { dataSourceId }, // 🆕 주 데이터 소스만 전달 (나머지는 modalDataStore에서 자동으로 찾음)
|
||||
splitPanelParentData: parentData, // 🆕 부모 데이터 전달 (excludeFilter에서 사용)
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@
|
|||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.525.0",
|
||||
"mammoth": "^1.11.0",
|
||||
"next": "15.4.4",
|
||||
"next": "^15.4.8",
|
||||
"react": "19.1.0",
|
||||
"react-day-picker": "^9.11.1",
|
||||
"react-dnd": "^16.0.1",
|
||||
|
|
@ -1145,9 +1145,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@next/env": {
|
||||
"version": "15.4.4",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.4.4.tgz",
|
||||
"integrity": "sha512-SJKOOkULKENyHSYXE5+KiFU6itcIb6wSBjgM92meK0HVKpo94dNOLZVdLLuS7/BxImROkGoPsjR4EnuDucqiiA==",
|
||||
"version": "15.4.8",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.4.8.tgz",
|
||||
"integrity": "sha512-LydLa2MDI1NMrOFSkO54mTc8iIHSttj6R6dthITky9ylXV2gCGi0bHQjVCtLGRshdRPjyh2kXbxJukDtBWQZtQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@next/eslint-plugin-next": {
|
||||
|
|
@ -1161,9 +1161,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-arm64": {
|
||||
"version": "15.4.4",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.4.4.tgz",
|
||||
"integrity": "sha512-eVG55dnGwfUuG+TtnUCt+mEJ+8TGgul6nHEvdb8HEH7dmJIFYOCApAaFrIrxwtEq2Cdf+0m5sG1Np8cNpw9EAw==",
|
||||
"version": "15.4.8",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.4.8.tgz",
|
||||
"integrity": "sha512-Pf6zXp7yyQEn7sqMxur6+kYcywx5up1J849psyET7/8pG2gQTVMjU3NzgIt8SeEP5to3If/SaWmaA6H6ysBr1A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -1177,9 +1177,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-x64": {
|
||||
"version": "15.4.4",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.4.4.tgz",
|
||||
"integrity": "sha512-zqG+/8apsu49CltEj4NAmCGZvHcZbOOOsNoTVeIXphYWIbE4l6A/vuQHyqll0flU2o3dmYCXsBW5FmbrGDgljQ==",
|
||||
"version": "15.4.8",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.4.8.tgz",
|
||||
"integrity": "sha512-xla6AOfz68a6kq3gRQccWEvFC/VRGJmA/QuSLENSO7CZX5WIEkSz7r1FdXUjtGCQ1c2M+ndUAH7opdfLK1PQbw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -1193,9 +1193,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
||||
"version": "15.4.4",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.4.4.tgz",
|
||||
"integrity": "sha512-LRD4l2lq4R+2QCHBQVC0wjxxkLlALGJCwigaJ5FSRSqnje+MRKHljQNZgDCaKUZQzO/TXxlmUdkZP/X3KNGZaw==",
|
||||
"version": "15.4.8",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.4.8.tgz",
|
||||
"integrity": "sha512-y3fmp+1Px/SJD+5ntve5QLZnGLycsxsVPkTzAc3zUiXYSOlTPqT8ynfmt6tt4fSo1tAhDPmryXpYKEAcoAPDJw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -1209,9 +1209,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-musl": {
|
||||
"version": "15.4.4",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.4.4.tgz",
|
||||
"integrity": "sha512-LsGUCTvuZ0690fFWerA4lnQvjkYg9gHo12A3wiPUR4kCxbx/d+SlwmonuTH2SWZI+RVGA9VL3N0S03WTYv6bYg==",
|
||||
"version": "15.4.8",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.4.8.tgz",
|
||||
"integrity": "sha512-DX/L8VHzrr1CfwaVjBQr3GWCqNNFgyWJbeQ10Lx/phzbQo3JNAxUok1DZ8JHRGcL6PgMRgj6HylnLNndxn4Z6A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -1225,9 +1225,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-gnu": {
|
||||
"version": "15.4.4",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.4.4.tgz",
|
||||
"integrity": "sha512-aOy5yNRpLL3wNiJVkFYl6w22hdREERNjvegE6vvtix8LHRdsTHhWTpgvcYdCK7AIDCQW5ATmzr9XkPHvSoAnvg==",
|
||||
"version": "15.4.8",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.4.8.tgz",
|
||||
"integrity": "sha512-9fLAAXKAL3xEIFdKdzG5rUSvSiZTLLTCc6JKq1z04DR4zY7DbAPcRvNm3K1inVhTiQCs19ZRAgUerHiVKMZZIA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -1241,9 +1241,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-musl": {
|
||||
"version": "15.4.4",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.4.4.tgz",
|
||||
"integrity": "sha512-FL7OAn4UkR8hKQRGBmlHiHinzOb07tsfARdGh7v0Z0jEJ3sz8/7L5bR23ble9E6DZMabSStqlATHlSxv1fuzAg==",
|
||||
"version": "15.4.8",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.4.8.tgz",
|
||||
"integrity": "sha512-s45V7nfb5g7dbS7JK6XZDcapicVrMMvX2uYgOHP16QuKH/JA285oy6HcxlKqwUNaFY/UC6EvQ8QZUOo19cBKSA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -1257,9 +1257,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-arm64-msvc": {
|
||||
"version": "15.4.4",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.4.4.tgz",
|
||||
"integrity": "sha512-eEdNW/TXwjYhOulQh0pffTMMItWVwKCQpbziSBmgBNFZIIRn2GTXrhrewevs8wP8KXWYMx8Z+mNU0X+AfvtrRg==",
|
||||
"version": "15.4.8",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.4.8.tgz",
|
||||
"integrity": "sha512-KjgeQyOAq7t/HzAJcWPGA8X+4WY03uSCZ2Ekk98S9OgCFsb6lfBE3dbUzUuEQAN2THbwYgFfxX2yFTCMm8Kehw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -1273,9 +1273,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-x64-msvc": {
|
||||
"version": "15.4.4",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.4.4.tgz",
|
||||
"integrity": "sha512-SE5pYNbn/xZKMy1RE3pAs+4xD32OI4rY6mzJa4XUkp/ItZY+OMjIgilskmErt8ls/fVJ+Ihopi2QIeW6O3TrMw==",
|
||||
"version": "15.4.8",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.4.8.tgz",
|
||||
"integrity": "sha512-Exsmf/+42fWVnLMaZHzshukTBxZrSwuuLKFvqhGHJ+mC1AokqieLY/XzAl3jc/CqhXLqLY3RRjkKJ9YnLPcRWg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -10876,12 +10876,12 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/next": {
|
||||
"version": "15.4.4",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-15.4.4.tgz",
|
||||
"integrity": "sha512-kNcubvJjOL9yUOfwtZF3HfDhuhp+kVD+FM2A6Tyua1eI/xfmY4r/8ZS913MMz+oWKDlbps/dQOWdDricuIkXLw==",
|
||||
"version": "15.4.8",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-15.4.8.tgz",
|
||||
"integrity": "sha512-jwOXTz/bo0Pvlf20FSb6VXVeWRssA2vbvq9SdrOPEg9x8E1B27C2rQtvriAn600o9hH61kjrVRexEffv3JybuA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@next/env": "15.4.4",
|
||||
"@next/env": "15.4.8",
|
||||
"@swc/helpers": "0.5.15",
|
||||
"caniuse-lite": "^1.0.30001579",
|
||||
"postcss": "8.4.31",
|
||||
|
|
@ -10894,14 +10894,14 @@
|
|||
"node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@next/swc-darwin-arm64": "15.4.4",
|
||||
"@next/swc-darwin-x64": "15.4.4",
|
||||
"@next/swc-linux-arm64-gnu": "15.4.4",
|
||||
"@next/swc-linux-arm64-musl": "15.4.4",
|
||||
"@next/swc-linux-x64-gnu": "15.4.4",
|
||||
"@next/swc-linux-x64-musl": "15.4.4",
|
||||
"@next/swc-win32-arm64-msvc": "15.4.4",
|
||||
"@next/swc-win32-x64-msvc": "15.4.4",
|
||||
"@next/swc-darwin-arm64": "15.4.8",
|
||||
"@next/swc-darwin-x64": "15.4.8",
|
||||
"@next/swc-linux-arm64-gnu": "15.4.8",
|
||||
"@next/swc-linux-arm64-musl": "15.4.8",
|
||||
"@next/swc-linux-x64-gnu": "15.4.8",
|
||||
"@next/swc-linux-x64-musl": "15.4.8",
|
||||
"@next/swc-win32-arm64-msvc": "15.4.8",
|
||||
"@next/swc-win32-x64-msvc": "15.4.8",
|
||||
"sharp": "^0.34.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@
|
|||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.525.0",
|
||||
"mammoth": "^1.11.0",
|
||||
"next": "15.4.4",
|
||||
"next": "^15.4.8",
|
||||
"react": "19.1.0",
|
||||
"react-day-picker": "^9.11.1",
|
||||
"react-dnd": "^16.0.1",
|
||||
|
|
|
|||
|
|
@ -1679,3 +1679,4 @@ const 출고등록_설정: ScreenSplitPanel = {
|
|||
|
||||
화면 임베딩 및 데이터 전달 시스템은 복잡한 업무 워크플로우를 효율적으로 처리할 수 있는 강력한 기능입니다. 단계별로 체계적으로 구현하면 약 3.5개월 내에 완성할 수 있으며, 이를 통해 사용자 경험을 크게 향상시킬 수 있습니다.
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -526,3 +526,4 @@ const { data: config } = await getScreenSplitPanel(screenId);
|
|||
|
||||
이제 입고 등록과 같은 복잡한 워크플로우를 구현할 수 있습니다. 다음 단계는 각 컴포넌트 타입별 DataReceivable 인터페이스 구현과 설정 UI 개발입니다.
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -513,3 +513,4 @@ function ScreenViewPage() {
|
|||
|
||||
새로운 시스템은 기존 시스템과 **독립적으로 동작**하며, 최소한의 수정만으로 통합 가능합니다. 화면 페이지에 조건 분기만 추가하면 바로 사용할 수 있습니다.
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue