Compare commits

...

15 Commits

Author SHA1 Message Date
kjs 44c76d80b7 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management 2025-12-01 18:39:20 +09:00
SeongHyun Kim fb068284db Merge branch 'ksh' 2025-12-01 18:36:06 +09:00
SeongHyun Kim 0281d3722e revert: SelectBasicComponent.tsx 이전 상태로 복원 2025-12-01 18:35:55 +09:00
hjlee 18521339bb Merge pull request 'lhj' (#233) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/233
2025-12-01 17:05:34 +09:00
leeheejin 7242f08224 공차 등록, 연속추적 기능 2025-12-01 17:04:59 +09:00
leeheejin fbeb3ec2c9 버튼 과정이 조금 복잡하지만 위도경도 연속추적기능도 넣음 2025-12-01 16:49:02 +09:00
hjlee 15d5708b5d Merge pull request 'lhj' (#232) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/232
2025-12-01 15:46:13 +09:00
leeheejin 7263c9c3ff Merge origin/main into lhj - resolve buttonActions.ts conflict 2025-12-01 15:44:19 +09:00
leeheejin 6545410d49 공차등록 기능 구현 2025-12-01 15:42:40 +09:00
kjs 36132bf07c Merge pull request 'feature/screen-management' (#231) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/231
2025-12-01 15:30:43 +09:00
leeheejin 8d2ec8e737 공차등록성공 2025-12-01 15:23:07 +09:00
leeheejin be2550885a Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2025-12-01 14:45:51 +09:00
leeheejin fd7a1bbf53 출발지도착지 선택 가능하고 교환버튼 작동하게 2025-12-01 12:27:24 +09:00
leeheejin d7ee63a857 출발지 목적지 선택 2025-12-01 11:07:16 +09:00
leeheejin c657d6f7a0 출발지 도착지 2025-12-01 10:32:12 +09:00
11 changed files with 1645 additions and 419 deletions

View File

@ -482,3 +482,125 @@ export const updateFieldValue = async (
}); });
} }
}; };
/**
* ( )
* POST /api/dynamic-form/location-history
*/
export const saveLocationHistory = async (
req: AuthenticatedRequest,
res: Response
): Promise<Response | void> => {
try {
const { companyCode, userId } = req.user as any;
const {
latitude,
longitude,
accuracy,
altitude,
speed,
heading,
tripId,
tripStatus,
departure,
arrival,
departureName,
destinationName,
recordedAt,
vehicleId,
} = req.body;
console.log("📍 [saveLocationHistory] 요청:", {
userId,
companyCode,
latitude,
longitude,
tripId,
});
// 필수 필드 검증
if (latitude === undefined || longitude === undefined) {
return res.status(400).json({
success: false,
message: "필수 필드가 누락되었습니다. (latitude, longitude)",
});
}
const result = await dynamicFormService.saveLocationHistory({
userId,
companyCode,
latitude,
longitude,
accuracy,
altitude,
speed,
heading,
tripId,
tripStatus: tripStatus || "active",
departure,
arrival,
departureName,
destinationName,
recordedAt: recordedAt || new Date().toISOString(),
vehicleId,
});
console.log("✅ [saveLocationHistory] 성공:", result);
res.json({
success: true,
data: result,
message: "위치 이력이 저장되었습니다.",
});
} catch (error: any) {
console.error("❌ [saveLocationHistory] 실패:", error);
res.status(500).json({
success: false,
message: error.message || "위치 이력 저장에 실패했습니다.",
});
}
};
/**
* ( )
* GET /api/dynamic-form/location-history/:tripId
*/
export const getLocationHistory = async (
req: AuthenticatedRequest,
res: Response
): Promise<Response | void> => {
try {
const { companyCode } = req.user as any;
const { tripId } = req.params;
const { userId, startDate, endDate, limit } = req.query;
console.log("📍 [getLocationHistory] 요청:", {
tripId,
userId,
startDate,
endDate,
limit,
});
const result = await dynamicFormService.getLocationHistory({
companyCode,
tripId,
userId: userId as string,
startDate: startDate as string,
endDate: endDate as string,
limit: limit ? parseInt(limit as string) : 1000,
});
res.json({
success: true,
data: result,
count: result.length,
});
} catch (error: any) {
console.error("❌ [getLocationHistory] 실패:", error);
res.status(500).json({
success: false,
message: error.message || "위치 이력 조회에 실패했습니다.",
});
}
};

View File

@ -12,6 +12,8 @@ import {
validateFormData, validateFormData,
getTableColumns, getTableColumns,
getTablePrimaryKeys, getTablePrimaryKeys,
saveLocationHistory,
getLocationHistory,
} from "../controllers/dynamicFormController"; } from "../controllers/dynamicFormController";
const router = express.Router(); const router = express.Router();
@ -22,9 +24,9 @@ router.use(authenticateToken);
// 폼 데이터 CRUD // 폼 데이터 CRUD
router.post("/save", saveFormData); // 기존 버전 (레거시 지원) router.post("/save", saveFormData); // 기존 버전 (레거시 지원)
router.post("/save-enhanced", saveFormDataEnhanced); // 개선된 버전 router.post("/save-enhanced", saveFormDataEnhanced); // 개선된 버전
router.put("/update-field", updateFieldValue); // 특정 필드만 업데이트 (다른 테이블 지원) - /:id 보다 먼저 선언!
router.put("/:id", updateFormData); router.put("/:id", updateFormData);
router.patch("/:id/partial", updateFormDataPartial); // 부분 업데이트 router.patch("/:id/partial", updateFormDataPartial); // 부분 업데이트
router.put("/update-field", updateFieldValue); // 특정 필드만 업데이트 (다른 테이블 지원)
router.delete("/:id", deleteFormData); router.delete("/:id", deleteFormData);
router.get("/:id", getFormData); router.get("/:id", getFormData);
@ -40,4 +42,8 @@ router.get("/table/:tableName/columns", getTableColumns);
// 테이블 기본키 조회 // 테이블 기본키 조회
router.get("/table/:tableName/primary-keys", getTablePrimaryKeys); router.get("/table/:tableName/primary-keys", getTablePrimaryKeys);
// 위치 이력 (연속 위치 추적)
router.post("/location-history", saveLocationHistory);
router.get("/location-history/:tripId", getLocationHistory);
export default router; export default router;

View File

@ -1662,12 +1662,47 @@ export class DynamicFormService {
companyCode, companyCode,
}); });
// 멀티테넌시: company_code 조건 추가 (최고관리자는 제외) // 테이블 컬럼 정보 조회 (updated_by, updated_at 존재 여부 확인)
let whereClause = `"${keyField}" = $1`; const columnQuery = `
const params: any[] = [keyValue, updateValue, userId]; SELECT column_name
let paramIndex = 4; FROM information_schema.columns
WHERE table_name = $1 AND column_name IN ('updated_by', 'updated_at', 'company_code')
`;
const columnResult = await client.query(columnQuery, [tableName]);
const existingColumns = columnResult.rows.map((row: any) => row.column_name);
if (companyCode && companyCode !== "*") { const hasUpdatedBy = existingColumns.includes('updated_by');
const hasUpdatedAt = existingColumns.includes('updated_at');
const hasCompanyCode = existingColumns.includes('company_code');
console.log("🔍 [updateFieldValue] 테이블 컬럼 확인:", {
hasUpdatedBy,
hasUpdatedAt,
hasCompanyCode,
});
// 동적 SET 절 구성
let setClause = `"${updateField}" = $1`;
const params: any[] = [updateValue];
let paramIndex = 2;
if (hasUpdatedBy) {
setClause += `, updated_by = $${paramIndex}`;
params.push(userId);
paramIndex++;
}
if (hasUpdatedAt) {
setClause += `, updated_at = NOW()`;
}
// WHERE 절 구성
let whereClause = `"${keyField}" = $${paramIndex}`;
params.push(keyValue);
paramIndex++;
// 멀티테넌시: company_code 조건 추가 (최고관리자는 제외, 컬럼이 있는 경우만)
if (hasCompanyCode && companyCode && companyCode !== "*") {
whereClause += ` AND company_code = $${paramIndex}`; whereClause += ` AND company_code = $${paramIndex}`;
params.push(companyCode); params.push(companyCode);
paramIndex++; paramIndex++;
@ -1675,9 +1710,7 @@ export class DynamicFormService {
const sqlQuery = ` const sqlQuery = `
UPDATE "${tableName}" UPDATE "${tableName}"
SET "${updateField}" = $2, SET ${setClause}
updated_by = $3,
updated_at = NOW()
WHERE ${whereClause} WHERE ${whereClause}
`; `;
@ -1698,6 +1731,191 @@ export class DynamicFormService {
client.release(); client.release();
} }
} }
/**
* ( )
*/
async saveLocationHistory(data: {
userId: string;
companyCode: string;
latitude: number;
longitude: number;
accuracy?: number;
altitude?: number;
speed?: number;
heading?: number;
tripId?: string;
tripStatus?: string;
departure?: string;
arrival?: string;
departureName?: string;
destinationName?: string;
recordedAt?: string;
vehicleId?: number;
}): Promise<{ id: number }> {
const pool = getPool();
const client = await pool.connect();
try {
console.log("📍 [saveLocationHistory] 저장 시작:", data);
const sqlQuery = `
INSERT INTO vehicle_location_history (
user_id,
company_code,
latitude,
longitude,
accuracy,
altitude,
speed,
heading,
trip_id,
trip_status,
departure,
arrival,
departure_name,
destination_name,
recorded_at,
vehicle_id
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
RETURNING id
`;
const params = [
data.userId,
data.companyCode,
data.latitude,
data.longitude,
data.accuracy || null,
data.altitude || null,
data.speed || null,
data.heading || null,
data.tripId || null,
data.tripStatus || "active",
data.departure || null,
data.arrival || null,
data.departureName || null,
data.destinationName || null,
data.recordedAt ? new Date(data.recordedAt) : new Date(),
data.vehicleId || null,
];
const result = await client.query(sqlQuery, params);
console.log("✅ [saveLocationHistory] 저장 완료:", {
id: result.rows[0]?.id,
});
return { id: result.rows[0]?.id };
} catch (error) {
console.error("❌ [saveLocationHistory] 오류:", error);
throw error;
} finally {
client.release();
}
}
/**
* ( )
*/
async getLocationHistory(params: {
companyCode: string;
tripId?: string;
userId?: string;
startDate?: string;
endDate?: string;
limit?: number;
}): Promise<any[]> {
const pool = getPool();
const client = await pool.connect();
try {
console.log("📍 [getLocationHistory] 조회 시작:", params);
const conditions: string[] = [];
const queryParams: any[] = [];
let paramIndex = 1;
// 멀티테넌시: company_code 필터
if (params.companyCode && params.companyCode !== "*") {
conditions.push(`company_code = $${paramIndex}`);
queryParams.push(params.companyCode);
paramIndex++;
}
// trip_id 필터
if (params.tripId) {
conditions.push(`trip_id = $${paramIndex}`);
queryParams.push(params.tripId);
paramIndex++;
}
// user_id 필터
if (params.userId) {
conditions.push(`user_id = $${paramIndex}`);
queryParams.push(params.userId);
paramIndex++;
}
// 날짜 범위 필터
if (params.startDate) {
conditions.push(`recorded_at >= $${paramIndex}`);
queryParams.push(new Date(params.startDate));
paramIndex++;
}
if (params.endDate) {
conditions.push(`recorded_at <= $${paramIndex}`);
queryParams.push(new Date(params.endDate));
paramIndex++;
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const limitClause = params.limit ? `LIMIT ${params.limit}` : "LIMIT 1000";
const sqlQuery = `
SELECT
id,
user_id,
vehicle_id,
latitude,
longitude,
accuracy,
altitude,
speed,
heading,
trip_id,
trip_status,
departure,
arrival,
departure_name,
destination_name,
recorded_at,
created_at,
company_code
FROM vehicle_location_history
${whereClause}
ORDER BY recorded_at ASC
${limitClause}
`;
console.log("🔍 [getLocationHistory] 쿼리:", sqlQuery);
console.log("🔍 [getLocationHistory] 파라미터:", queryParams);
const result = await client.query(sqlQuery, queryParams);
console.log("✅ [getLocationHistory] 조회 완료:", {
count: result.rowCount,
});
return result.rows;
} catch (error) {
console.error("❌ [getLocationHistory] 오류:", error);
throw error;
} finally {
client.release();
}
}
} }
// 싱글톤 인스턴스 생성 및 export // 싱글톤 인스턴스 생성 및 export

View File

@ -503,8 +503,8 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
<SelectItem value="excel_upload"> </SelectItem> <SelectItem value="excel_upload"> </SelectItem>
<SelectItem value="barcode_scan"> </SelectItem> <SelectItem value="barcode_scan"> </SelectItem>
<SelectItem value="code_merge"> </SelectItem> <SelectItem value="code_merge"> </SelectItem>
<SelectItem value="geolocation"> </SelectItem> <SelectItem value="empty_vehicle"></SelectItem>
<SelectItem value="update_field"> </SelectItem> <SelectItem value="operation_control"> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@ -1665,24 +1665,16 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
)} )}
{/* 위치정보 가져오기 설정 */} {/* 위치정보 가져오기 설정 */}
{(component.componentConfig?.action?.type || "save") === "geolocation" && ( {(component.componentConfig?.action?.type || "save") === "empty_vehicle" && (
<div className="mt-4 space-y-4 rounded-lg border bg-muted/50 p-4"> <div className="mt-4 space-y-4 rounded-lg border bg-muted/50 p-4">
<h4 className="text-sm font-medium text-foreground">📍 </h4> <h4 className="text-sm font-medium text-foreground">🚛 </h4>
{/* 테이블 선택 */} {/* 테이블 선택 */}
<div> <div>
<Label htmlFor="geolocation-table"> <Label htmlFor="geolocation-table"> </Label>
<span className="text-destructive">*</span>
</Label>
<Select <Select
value={config.action?.geolocationTableName || currentTableName || ""} value={config.action?.geolocationTableName || currentTableName || ""}
onValueChange={(value) => { onValueChange={(value) => onUpdateProperty("componentConfig.action.geolocationTableName", value)}
onUpdateProperty("componentConfig.action.geolocationTableName", value);
onUpdateProperty("componentConfig.action.geolocationLatField", "");
onUpdateProperty("componentConfig.action.geolocationLngField", "");
onUpdateProperty("componentConfig.action.geolocationAccuracyField", "");
onUpdateProperty("componentConfig.action.geolocationTimestampField", "");
}}
> >
<SelectTrigger className="h-8 text-xs"> <SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="테이블 선택" /> <SelectValue placeholder="테이블 선택" />
@ -1695,32 +1687,29 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
<p className="mt-1 text-xs text-muted-foreground">
(기본: 현재 )
</p>
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<Label htmlFor="geolocation-lat-field"> <Label htmlFor="geolocation-lat-field">
<span className="text-destructive">*</span> <span className="text-destructive">*</span>
</Label> </Label>
<Input <Input
id="geolocation-lat-field" id="geolocation-lat-field"
placeholder="예: latitude" placeholder="latitude"
value={config.action?.geolocationLatField || ""} value={config.action?.geolocationLatField || "latitude"}
onChange={(e) => onUpdateProperty("componentConfig.action.geolocationLatField", e.target.value)} onChange={(e) => onUpdateProperty("componentConfig.action.geolocationLatField", e.target.value)}
className="h-8 text-xs" className="h-8 text-xs"
/> />
</div> </div>
<div> <div>
<Label htmlFor="geolocation-lng-field"> <Label htmlFor="geolocation-lng-field">
<span className="text-destructive">*</span> <span className="text-destructive">*</span>
</Label> </Label>
<Input <Input
id="geolocation-lng-field" id="geolocation-lng-field"
placeholder="예: longitude" placeholder="longitude"
value={config.action?.geolocationLngField || ""} value={config.action?.geolocationLngField || "longitude"}
onChange={(e) => onUpdateProperty("componentConfig.action.geolocationLngField", e.target.value)} onChange={(e) => onUpdateProperty("componentConfig.action.geolocationLngField", e.target.value)}
className="h-8 text-xs" className="h-8 text-xs"
/> />
@ -1729,20 +1718,20 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<Label htmlFor="geolocation-accuracy-field"> ()</Label> <Label htmlFor="geolocation-accuracy-field"> ()</Label>
<Input <Input
id="geolocation-accuracy-field" id="geolocation-accuracy-field"
placeholder="예: accuracy" placeholder="accuracy"
value={config.action?.geolocationAccuracyField || ""} value={config.action?.geolocationAccuracyField || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.geolocationAccuracyField", e.target.value)} onChange={(e) => onUpdateProperty("componentConfig.action.geolocationAccuracyField", e.target.value)}
className="h-8 text-xs" className="h-8 text-xs"
/> />
</div> </div>
<div> <div>
<Label htmlFor="geolocation-timestamp-field"> ()</Label> <Label htmlFor="geolocation-timestamp-field"> ()</Label>
<Input <Input
id="geolocation-timestamp-field" id="geolocation-timestamp-field"
placeholder="예: location_time" placeholder="location_time"
value={config.action?.geolocationTimestampField || ""} value={config.action?.geolocationTimestampField || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.geolocationTimestampField", e.target.value)} onChange={(e) => onUpdateProperty("componentConfig.action.geolocationTimestampField", e.target.value)}
className="h-8 text-xs" className="h-8 text-xs"
@ -1753,7 +1742,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="space-y-0.5"> <div className="space-y-0.5">
<Label htmlFor="geolocation-high-accuracy"> </Label> <Label htmlFor="geolocation-high-accuracy"> </Label>
<p className="text-xs text-muted-foreground">GPS ( )</p> <p className="text-xs text-muted-foreground">GPS ( )</p>
</div> </div>
<Switch <Switch
id="geolocation-high-accuracy" id="geolocation-high-accuracy"
@ -1762,39 +1751,108 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
/> />
</div> </div>
<div className="flex items-center justify-between"> {/* 자동 저장 옵션 */}
<div className="space-y-0.5"> <div className="border-t pt-4">
<Label htmlFor="geolocation-auto-save"> </Label> <div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground"> </p> <div className="space-y-0.5">
<Label htmlFor="geolocation-auto-save">DB </Label>
<p className="text-xs text-muted-foreground"> DB에 </p>
</div>
<Switch
id="geolocation-auto-save"
checked={config.action?.geolocationAutoSave === true}
onCheckedChange={(checked) => onUpdateProperty("componentConfig.action.geolocationAutoSave", checked)}
/>
</div> </div>
<Switch
id="geolocation-auto-save" {config.action?.geolocationAutoSave && (
checked={config.action?.geolocationAutoSave === true} <div className="mt-3 space-y-3 rounded-md bg-amber-50 p-3 dark:bg-amber-950">
onCheckedChange={(checked) => onUpdateProperty("componentConfig.action.geolocationAutoSave", checked)} <div className="grid grid-cols-2 gap-2">
/> <div>
<Label> (WHERE )</Label>
<Input
placeholder="user_id"
value={config.action?.geolocationKeyField || "user_id"}
onChange={(e) => onUpdateProperty("componentConfig.action.geolocationKeyField", e.target.value)}
className="h-8 text-xs"
/>
</div>
<div>
<Label> </Label>
<Select
value={config.action?.geolocationKeySourceField || "__userId__"}
onValueChange={(value) => onUpdateProperty("componentConfig.action.geolocationKeySourceField", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="소스 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__userId__" className="text-xs font-medium text-blue-600">
🔑 ID
</SelectItem>
<SelectItem value="__companyCode__" className="text-xs font-medium text-blue-600">
🏢
</SelectItem>
<SelectItem value="__userName__" className="text-xs font-medium text-blue-600">
👤
</SelectItem>
{tableColumns.length > 0 && (
<>
<SelectItem value="__divider__" disabled className="text-xs text-muted-foreground">
</SelectItem>
{tableColumns.map((col) => (
<SelectItem key={col} value={col} className="text-xs">
{col}
</SelectItem>
))}
</>
)}
</SelectContent>
</Select>
</div>
</div>
{/* 추가 필드 변경 (status 등) */}
<div className="grid grid-cols-2 gap-2">
<div>
<Label> ()</Label>
<Input
placeholder="status"
value={config.action?.geolocationExtraField || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.geolocationExtraField", e.target.value)}
className="h-8 text-xs"
/>
</div>
<div>
<Label> </Label>
<Input
placeholder="inactive"
value={config.action?.geolocationExtraValue || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.geolocationExtraValue", e.target.value)}
className="h-8 text-xs"
/>
</div>
</div>
<p className="text-[10px] text-amber-700 dark:text-amber-300">
status .
</p>
</div>
)}
</div> </div>
<div className="rounded-md bg-blue-50 p-3 dark:bg-blue-950"> <div className="rounded-md bg-blue-50 p-3 dark:bg-blue-950">
<p className="text-xs text-blue-900 dark:text-blue-100"> <p className="text-xs text-blue-900 dark:text-blue-100">
<strong> :</strong> <strong>:</strong> HTTPS .
<br />
1.
<br />
2. GPS
<br />
3. /
<br />
<br />
<strong>:</strong> HTTPS .
</p> </p>
</div> </div>
</div> </div>
)} )}
{/* 필드 값 변경 설정 */} {/* 운행알림 및 종료 설정 */}
{(component.componentConfig?.action?.type || "save") === "update_field" && ( {(component.componentConfig?.action?.type || "save") === "operation_control" && (
<div className="mt-4 space-y-4 rounded-lg border bg-muted/50 p-4"> <div className="mt-4 space-y-4 rounded-lg border bg-muted/50 p-4">
<h4 className="text-sm font-medium text-foreground">📝 </h4> <h4 className="text-sm font-medium text-foreground">🚗 </h4>
<div> <div>
<Label htmlFor="update-table"> <Label htmlFor="update-table">
@ -1852,6 +1910,62 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
</div> </div>
</div> </div>
{/* 🆕 키 필드 설정 (레코드 식별용) */}
<div className="mt-4 border-t pt-4">
<h5 className="mb-3 text-xs font-medium text-muted-foreground"> </h5>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="update-key-field">
(DB ) <span className="text-destructive">*</span>
</Label>
<Input
id="update-key-field"
placeholder="예: user_id"
value={config.action?.updateKeyField || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.updateKeyField", e.target.value)}
className="h-8 text-xs"
/>
<p className="mt-1 text-xs text-muted-foreground"> DB </p>
</div>
<div>
<Label htmlFor="update-key-source">
<span className="text-destructive">*</span>
</Label>
<Select
value={config.action?.updateKeySourceField || ""}
onValueChange={(value) => onUpdateProperty("componentConfig.action.updateKeySourceField", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="키 값 소스 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__userId__" className="text-xs">
<span className="flex items-center gap-1">
<span className="text-amber-500">🔑</span> ID
</span>
</SelectItem>
<SelectItem value="__userName__" className="text-xs">
<span className="flex items-center gap-1">
<span className="text-amber-500">🔑</span>
</span>
</SelectItem>
<SelectItem value="__companyCode__" className="text-xs">
<span className="flex items-center gap-1">
<span className="text-amber-500">🔑</span>
</span>
</SelectItem>
{tableColumns.map((column) => (
<SelectItem key={column} value={column} className="text-xs">
{column}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="mt-1 text-xs text-muted-foreground"> </p>
</div>
</div>
</div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="space-y-0.5"> <div className="space-y-0.5">
<Label htmlFor="update-auto-save"> </Label> <Label htmlFor="update-auto-save"> </Label>
@ -1899,15 +2013,133 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
</div> </div>
</div> </div>
{/* 위치정보 수집 옵션 */}
<div className="mt-4 border-t pt-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="update-with-geolocation"> </Label>
<p className="text-xs text-muted-foreground"> GPS </p>
</div>
<Switch
id="update-with-geolocation"
checked={config.action?.updateWithGeolocation === true}
onCheckedChange={(checked) => onUpdateProperty("componentConfig.action.updateWithGeolocation", checked)}
/>
</div>
{config.action?.updateWithGeolocation && (
<div className="mt-3 space-y-3 rounded-md bg-amber-50 p-3 dark:bg-amber-950">
<div className="grid grid-cols-2 gap-2">
<div>
<Label> <span className="text-destructive">*</span></Label>
<Input
placeholder="예: latitude"
value={config.action?.updateGeolocationLatField || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.updateGeolocationLatField", e.target.value)}
className="h-8 text-xs"
/>
</div>
<div>
<Label> <span className="text-destructive">*</span></Label>
<Input
placeholder="예: longitude"
value={config.action?.updateGeolocationLngField || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.updateGeolocationLngField", e.target.value)}
className="h-8 text-xs"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<Label> ()</Label>
<Input
placeholder="예: accuracy"
value={config.action?.updateGeolocationAccuracyField || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.updateGeolocationAccuracyField", e.target.value)}
className="h-8 text-xs"
/>
</div>
<div>
<Label> ()</Label>
<Input
placeholder="예: location_time"
value={config.action?.updateGeolocationTimestampField || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.updateGeolocationTimestampField", e.target.value)}
className="h-8 text-xs"
/>
</div>
</div>
<p className="text-[10px] text-amber-700 dark:text-amber-300">
GPS .
</p>
</div>
)}
</div>
{/* 🆕 연속 위치 추적 설정 */}
<div className="flex items-center justify-between">
<div>
<Label htmlFor="update-with-tracking"> </Label>
<p className="text-xs text-muted-foreground">10 </p>
</div>
<Switch
id="update-with-tracking"
checked={config.action?.updateWithTracking === true}
onCheckedChange={(checked) => onUpdateProperty("componentConfig.action.updateWithTracking", checked)}
/>
</div>
{config.action?.updateWithTracking && (
<div className="mt-3 space-y-3 rounded-md bg-green-50 p-3 dark:bg-green-950">
<div>
<Label> <span className="text-destructive">*</span></Label>
<Select
value={config.action?.updateTrackingMode || "start"}
onValueChange={(value) => onUpdateProperty("componentConfig.action.updateTrackingMode", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="모드 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="start"> ( )</SelectItem>
<SelectItem value="stop"> ( )</SelectItem>
</SelectContent>
</Select>
</div>
{config.action?.updateTrackingMode === "start" && (
<div>
<Label> ()</Label>
<Input
type="number"
placeholder="10"
value={(config.action?.updateTrackingInterval || 10000) / 1000}
onChange={(e) => onUpdateProperty("componentConfig.action.updateTrackingInterval", parseInt(e.target.value) * 1000 || 10000)}
className="h-8 text-xs"
min={5}
max={300}
/>
<p className="mt-1 text-[10px] text-muted-foreground">5 ~ 300 (기본: 10초)</p>
</div>
)}
<p className="text-[10px] text-green-700 dark:text-green-300">
{config.action?.updateTrackingMode === "start"
? "버튼 클릭 시 연속 위치 추적이 시작되고, vehicle_location_history 테이블에 경로가 저장됩니다."
: "버튼 클릭 시 진행 중인 위치 추적이 종료됩니다."}
</p>
</div>
)}
<div className="rounded-md bg-blue-50 p-3 dark:bg-blue-950"> <div className="rounded-md bg-blue-50 p-3 dark:bg-blue-950">
<p className="text-xs text-blue-900 dark:text-blue-100"> <p className="text-xs text-blue-900 dark:text-blue-100">
<strong> :</strong> <strong> :</strong>
<br /> <br />
- 버튼: status &quot;active&quot; - 시작: status&quot;active&quot; +
<br /> <br />
- 버튼: approval_status &quot;approved&quot; - 종료: status를 &quot;completed&quot; +
<br /> <br />
- 버튼: is_completed &quot;Y&quot; - 공차등록: status를 &quot;inactive&quot; + 1
</p> </p>
</div> </div>
</div> </div>

View File

@ -53,6 +53,7 @@ export const DividerLineComponent: React.FC<DividerLineComponentProps> = ({
}; };
// DOM에 전달하면 안 되는 React-specific props 필터링 // DOM에 전달하면 안 되는 React-specific props 필터링
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { const {
selectedScreen, selectedScreen,
onZoneComponentDrop, onZoneComponentDrop,
@ -70,8 +71,40 @@ export const DividerLineComponent: React.FC<DividerLineComponentProps> = ({
tableName: _tableName, tableName: _tableName,
onRefresh: _onRefresh, onRefresh: _onRefresh,
onClose: _onClose, onClose: _onClose,
// 추가된 props 필터링
webType: _webType,
autoGeneration: _autoGeneration,
isInteractive: _isInteractive,
formData: _formData,
onFormDataChange: _onFormDataChange,
menuId: _menuId,
menuObjid: _menuObjid,
onSave: _onSave,
userId: _userId,
userName: _userName,
companyCode: _companyCode,
isInModal: _isInModal,
readonly: _readonly,
originalData: _originalData,
allComponents: _allComponents,
onUpdateLayout: _onUpdateLayout,
selectedRows: _selectedRows,
selectedRowsData: _selectedRowsData,
onSelectedRowsChange: _onSelectedRowsChange,
sortBy: _sortBy,
sortOrder: _sortOrder,
tableDisplayData: _tableDisplayData,
flowSelectedData: _flowSelectedData,
flowSelectedStepId: _flowSelectedStepId,
onFlowSelectedDataChange: _onFlowSelectedDataChange,
onConfigChange: _onConfigChange,
refreshKey: _refreshKey,
flowRefreshKey: _flowRefreshKey,
onFlowRefresh: _onFlowRefresh,
isPreview: _isPreview,
groupedData: _groupedData,
...domProps ...domProps
} = props; } = props as any;
return ( return (
<div style={componentStyle} className={className} {...domProps}> <div style={componentStyle} className={className} {...domProps}>

View File

@ -94,20 +94,51 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
const showSwapButton = config.showSwapButton !== false && props.showSwapButton !== false; const showSwapButton = config.showSwapButton !== false && props.showSwapButton !== false;
const variant = config.variant || props.variant || "card"; const variant = config.variant || props.variant || "card";
// 기본 옵션 (포항/광양)
const DEFAULT_OPTIONS: LocationOption[] = [
{ value: "pohang", label: "포항" },
{ value: "gwangyang", label: "광양" },
];
// 상태 // 상태
const [options, setOptions] = useState<LocationOption[]>([]); const [options, setOptions] = useState<LocationOption[]>(DEFAULT_OPTIONS);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [isSwapping, setIsSwapping] = useState(false); const [isSwapping, setIsSwapping] = useState(false);
// 현재 선택된 값 // 로컬 선택 상태 (Select 컴포넌트용)
const departureValue = formData[departureField] || ""; const [localDeparture, setLocalDeparture] = useState<string>("");
const destinationValue = formData[destinationField] || ""; const [localDestination, setLocalDestination] = useState<string>("");
// 옵션 로드 // 옵션 로드
useEffect(() => { useEffect(() => {
const loadOptions = async () => { const loadOptions = async () => {
if (dataSource.type === "static") { console.log("[LocationSwapSelector] 옵션 로드 시작:", { dataSource, isDesignMode });
setOptions(dataSource.staticOptions || []);
// 정적 옵션 처리 (기본값)
// type이 없거나 static이거나, table인데 tableName이 없는 경우
const shouldUseStatic =
!dataSource.type ||
dataSource.type === "static" ||
(dataSource.type === "table" && !dataSource.tableName) ||
(dataSource.type === "code" && !dataSource.codeCategory);
if (shouldUseStatic) {
const staticOpts = dataSource.staticOptions || [];
// 정적 옵션이 설정되어 있고, value가 유효한 경우 사용
// (value가 필드명과 같으면 잘못 설정된 것이므로 기본값 사용)
const isValidOptions = staticOpts.length > 0 &&
staticOpts[0]?.value &&
staticOpts[0].value !== departureField &&
staticOpts[0].value !== destinationField;
if (isValidOptions) {
console.log("[LocationSwapSelector] 정적 옵션 사용:", staticOpts);
setOptions(staticOpts);
} else {
// 기본값 (포항/광양)
console.log("[LocationSwapSelector] 기본 옵션 사용 (잘못된 설정 감지):", { staticOpts, DEFAULT_OPTIONS });
setOptions(DEFAULT_OPTIONS);
}
return; return;
} }
@ -115,11 +146,13 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
// 코드 관리에서 가져오기 // 코드 관리에서 가져오기
setLoading(true); setLoading(true);
try { try {
const response = await apiClient.get(`/api/codes/${dataSource.codeCategory}`); const response = await apiClient.get(`/code-management/codes`, {
params: { categoryCode: dataSource.codeCategory },
});
if (response.data.success && response.data.data) { if (response.data.success && response.data.data) {
const codeOptions = response.data.data.map((code: any) => ({ const codeOptions = response.data.data.map((code: any) => ({
value: code.code_value || code.codeValue, value: code.code_value || code.codeValue || code.code,
label: code.code_name || code.codeName, label: code.code_name || code.codeName || code.name,
})); }));
setOptions(codeOptions); setOptions(codeOptions);
} }
@ -135,13 +168,17 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
// 테이블에서 가져오기 // 테이블에서 가져오기
setLoading(true); setLoading(true);
try { try {
const response = await apiClient.get(`/api/dynamic/${dataSource.tableName}`, { const response = await apiClient.get(`/dynamic-form/list/${dataSource.tableName}`, {
params: { pageSize: 1000 }, params: { page: 1, pageSize: 1000 },
}); });
if (response.data.success && response.data.data) { if (response.data.success && response.data.data) {
const tableOptions = response.data.data.map((row: any) => ({ // data가 배열인지 또는 data.rows인지 확인
value: row[dataSource.valueField || "id"], const rows = Array.isArray(response.data.data)
label: row[dataSource.labelField || "name"], ? response.data.data
: response.data.data.rows || [];
const tableOptions = rows.map((row: any) => ({
value: String(row[dataSource.valueField || "id"] || ""),
label: String(row[dataSource.labelField || "name"] || ""),
})); }));
setOptions(tableOptions); setOptions(tableOptions);
} }
@ -153,81 +190,129 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
} }
}; };
if (!isDesignMode) { loadOptions();
loadOptions();
} else {
// 디자인 모드에서는 샘플 데이터
setOptions([
{ value: "seoul", label: "서울" },
{ value: "busan", label: "부산" },
{ value: "pohang", label: "포항" },
{ value: "gwangyang", label: "광양" },
]);
}
}, [dataSource, isDesignMode]); }, [dataSource, isDesignMode]);
// formData에서 초기값 동기화
useEffect(() => {
const depVal = formData[departureField];
const destVal = formData[destinationField];
if (depVal && options.some(o => o.value === depVal)) {
setLocalDeparture(depVal);
}
if (destVal && options.some(o => o.value === destVal)) {
setLocalDestination(destVal);
}
}, [formData, departureField, destinationField, options]);
// 출발지 변경 // 출발지 변경
const handleDepartureChange = (value: string) => { const handleDepartureChange = (selectedValue: string) => {
console.log("[LocationSwapSelector] 출발지 변경:", {
selectedValue,
departureField,
hasOnFormDataChange: !!onFormDataChange,
options
});
// 로컬 상태 업데이트
setLocalDeparture(selectedValue);
// 부모에게 전달
if (onFormDataChange) { if (onFormDataChange) {
onFormDataChange(departureField, value); console.log(`[LocationSwapSelector] onFormDataChange 호출: ${departureField} = ${selectedValue}`);
onFormDataChange(departureField, selectedValue);
// 라벨 필드도 업데이트 // 라벨 필드도 업데이트
if (departureLabelField) { if (departureLabelField) {
const selectedOption = options.find((opt) => opt.value === value); const selectedOption = options.find((opt) => opt.value === selectedValue);
onFormDataChange(departureLabelField, selectedOption?.label || ""); if (selectedOption) {
console.log(`[LocationSwapSelector] onFormDataChange 호출: ${departureLabelField} = ${selectedOption.label}`);
onFormDataChange(departureLabelField, selectedOption.label);
}
} }
} else {
console.warn("[LocationSwapSelector] ⚠️ onFormDataChange가 없습니다!");
} }
}; };
// 도착지 변경 // 도착지 변경
const handleDestinationChange = (value: string) => { const handleDestinationChange = (selectedValue: string) => {
console.log("[LocationSwapSelector] 도착지 변경:", {
selectedValue,
destinationField,
hasOnFormDataChange: !!onFormDataChange,
options
});
// 로컬 상태 업데이트
setLocalDestination(selectedValue);
// 부모에게 전달
if (onFormDataChange) { if (onFormDataChange) {
onFormDataChange(destinationField, value); console.log(`[LocationSwapSelector] onFormDataChange 호출: ${destinationField} = ${selectedValue}`);
onFormDataChange(destinationField, selectedValue);
// 라벨 필드도 업데이트 // 라벨 필드도 업데이트
if (destinationLabelField) { if (destinationLabelField) {
const selectedOption = options.find((opt) => opt.value === value); const selectedOption = options.find((opt) => opt.value === selectedValue);
onFormDataChange(destinationLabelField, selectedOption?.label || ""); if (selectedOption) {
console.log(`[LocationSwapSelector] onFormDataChange 호출: ${destinationLabelField} = ${selectedOption.label}`);
onFormDataChange(destinationLabelField, selectedOption.label);
}
} }
} else {
console.warn("[LocationSwapSelector] ⚠️ onFormDataChange가 없습니다!");
} }
}; };
// 출발지/도착지 교환 // 출발지/도착지 교환
const handleSwap = () => { const handleSwap = () => {
if (!onFormDataChange) return;
setIsSwapping(true); setIsSwapping(true);
// 교환 // 로컬 상태 교환
const tempDeparture = departureValue; const tempDeparture = localDeparture;
const tempDestination = destinationValue; const tempDestination = localDestination;
onFormDataChange(departureField, tempDestination); setLocalDeparture(tempDestination);
onFormDataChange(destinationField, tempDeparture); setLocalDestination(tempDeparture);
// 라벨도 교환 // 부모에게 전달
if (departureLabelField && destinationLabelField) { if (onFormDataChange) {
const tempDepartureLabel = formData[departureLabelField]; onFormDataChange(departureField, tempDestination);
const tempDestinationLabel = formData[destinationLabelField]; onFormDataChange(destinationField, tempDeparture);
onFormDataChange(departureLabelField, tempDestinationLabel);
onFormDataChange(destinationLabelField, tempDepartureLabel); // 라벨도 교환
if (departureLabelField && destinationLabelField) {
const depOption = options.find(o => o.value === tempDestination);
const destOption = options.find(o => o.value === tempDeparture);
onFormDataChange(departureLabelField, depOption?.label || "");
onFormDataChange(destinationLabelField, destOption?.label || "");
}
} }
// 애니메이션 효과 // 애니메이션 효과
setTimeout(() => setIsSwapping(false), 300); setTimeout(() => setIsSwapping(false), 300);
}; };
// 스타일에서 width, height 추출
const { width, height, ...restStyle } = style || {};
// 선택된 라벨 가져오기 // 선택된 라벨 가져오기
const getDepartureLabel = () => { const getDepartureLabel = () => {
const option = options.find((opt) => opt.value === departureValue); const opt = options.find(o => o.value === localDeparture);
return option?.label || "선택"; return opt?.label || "";
}; };
const getDestinationLabel = () => { const getDestinationLabel = () => {
const option = options.find((opt) => opt.value === destinationValue); const opt = options.find(o => o.value === localDestination);
return option?.label || "선택"; return opt?.label || "";
}; };
// 스타일에서 width, height 추출 // 디버그 로그
const { width, height, ...restStyle } = style || {}; console.log("[LocationSwapSelector] 렌더:", {
localDeparture,
localDestination,
options: options.map(o => `${o.value}:${o.label}`),
});
// Card 스타일 (이미지 참고) // Card 스타일 (이미지 참고)
if (variant === "card") { if (variant === "card") {
@ -242,18 +327,21 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
<div className="flex flex-1 flex-col items-center"> <div className="flex flex-1 flex-col items-center">
<span className="mb-1 text-xs text-muted-foreground">{departureLabel}</span> <span className="mb-1 text-xs text-muted-foreground">{departureLabel}</span>
<Select <Select
value={departureValue} value={localDeparture || undefined}
onValueChange={handleDepartureChange} onValueChange={handleDepartureChange}
disabled={loading || isDesignMode} disabled={loading}
> >
<SelectTrigger className="h-auto w-full max-w-[120px] border-0 bg-transparent p-0 text-center text-lg font-bold shadow-none focus:ring-0"> <SelectTrigger className={cn(
<SelectValue placeholder="선택"> "h-auto w-full max-w-[120px] border-0 bg-transparent p-0 text-center text-lg font-bold shadow-none focus:ring-0",
<span className={cn(isSwapping && "animate-pulse")}> isSwapping && "animate-pulse"
{getDepartureLabel()} )}>
</span> {localDeparture ? (
</SelectValue> <span>{getDepartureLabel()}</span>
) : (
<span className="text-muted-foreground"></span>
)}
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent position="popper" sideOffset={4}>
{options.map((option) => ( {options.map((option) => (
<SelectItem key={option.value} value={option.value}> <SelectItem key={option.value} value={option.value}>
{option.label} {option.label}
@ -270,7 +358,6 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={handleSwap} onClick={handleSwap}
disabled={isDesignMode || !departureValue || !destinationValue}
className={cn( className={cn(
"mx-2 h-10 w-10 rounded-full border bg-background transition-transform hover:bg-muted", "mx-2 h-10 w-10 rounded-full border bg-background transition-transform hover:bg-muted",
isSwapping && "rotate-180" isSwapping && "rotate-180"
@ -284,18 +371,21 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
<div className="flex flex-1 flex-col items-center"> <div className="flex flex-1 flex-col items-center">
<span className="mb-1 text-xs text-muted-foreground">{destinationLabel}</span> <span className="mb-1 text-xs text-muted-foreground">{destinationLabel}</span>
<Select <Select
value={destinationValue} value={localDestination || undefined}
onValueChange={handleDestinationChange} onValueChange={handleDestinationChange}
disabled={loading || isDesignMode} disabled={loading}
> >
<SelectTrigger className="h-auto w-full max-w-[120px] border-0 bg-transparent p-0 text-center text-lg font-bold shadow-none focus:ring-0"> <SelectTrigger className={cn(
<SelectValue placeholder="선택"> "h-auto w-full max-w-[120px] border-0 bg-transparent p-0 text-center text-lg font-bold shadow-none focus:ring-0",
<span className={cn(isSwapping && "animate-pulse")}> isSwapping && "animate-pulse"
{getDestinationLabel()} )}>
</span> {localDestination ? (
</SelectValue> <span>{getDestinationLabel()}</span>
) : (
<span className="text-muted-foreground"></span>
)}
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent position="popper" sideOffset={4}>
{options.map((option) => ( {options.map((option) => (
<SelectItem key={option.value} value={option.value}> <SelectItem key={option.value} value={option.value}>
{option.label} {option.label}
@ -320,14 +410,14 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
<div className="flex-1"> <div className="flex-1">
<label className="mb-1 block text-xs text-muted-foreground">{departureLabel}</label> <label className="mb-1 block text-xs text-muted-foreground">{departureLabel}</label>
<Select <Select
value={departureValue} value={localDeparture || undefined}
onValueChange={handleDepartureChange} onValueChange={handleDepartureChange}
disabled={loading || isDesignMode} disabled={loading}
> >
<SelectTrigger className="h-10"> <SelectTrigger className="h-10">
<SelectValue placeholder="선택" /> {localDeparture ? getDepartureLabel() : <span className="text-muted-foreground"></span>}
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent position="popper" sideOffset={4}>
{options.map((option) => ( {options.map((option) => (
<SelectItem key={option.value} value={option.value}> <SelectItem key={option.value} value={option.value}>
{option.label} {option.label}
@ -343,7 +433,6 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
variant="outline" variant="outline"
size="icon" size="icon"
onClick={handleSwap} onClick={handleSwap}
disabled={isDesignMode}
className="mt-5 h-10 w-10" className="mt-5 h-10 w-10"
> >
<ArrowLeftRight className="h-4 w-4" /> <ArrowLeftRight className="h-4 w-4" />
@ -353,14 +442,14 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
<div className="flex-1"> <div className="flex-1">
<label className="mb-1 block text-xs text-muted-foreground">{destinationLabel}</label> <label className="mb-1 block text-xs text-muted-foreground">{destinationLabel}</label>
<Select <Select
value={destinationValue} value={localDestination || undefined}
onValueChange={handleDestinationChange} onValueChange={handleDestinationChange}
disabled={loading || isDesignMode} disabled={loading}
> >
<SelectTrigger className="h-10"> <SelectTrigger className="h-10">
<SelectValue placeholder="선택" /> {localDestination ? getDestinationLabel() : <span className="text-muted-foreground"></span>}
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent position="popper" sideOffset={4}>
{options.map((option) => ( {options.map((option) => (
<SelectItem key={option.value} value={option.value}> <SelectItem key={option.value} value={option.value}>
{option.label} {option.label}
@ -381,14 +470,14 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
style={restStyle} style={restStyle}
> >
<Select <Select
value={departureValue} value={localDeparture || undefined}
onValueChange={handleDepartureChange} onValueChange={handleDepartureChange}
disabled={loading || isDesignMode} disabled={loading}
> >
<SelectTrigger className="h-8 flex-1 text-sm"> <SelectTrigger className="h-8 flex-1 text-sm">
<SelectValue placeholder={departureLabel} /> {localDeparture ? getDepartureLabel() : <span className="text-muted-foreground">{departureLabel}</span>}
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent position="popper" sideOffset={4}>
{options.map((option) => ( {options.map((option) => (
<SelectItem key={option.value} value={option.value}> <SelectItem key={option.value} value={option.value}>
{option.label} {option.label}
@ -403,7 +492,6 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={handleSwap} onClick={handleSwap}
disabled={isDesignMode}
className="h-8 w-8 p-0" className="h-8 w-8 p-0"
> >
<ArrowLeftRight className="h-4 w-4" /> <ArrowLeftRight className="h-4 w-4" />
@ -411,14 +499,14 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
)} )}
<Select <Select
value={destinationValue} value={localDestination || undefined}
onValueChange={handleDestinationChange} onValueChange={handleDestinationChange}
disabled={loading || isDesignMode} disabled={loading}
> >
<SelectTrigger className="h-8 flex-1 text-sm"> <SelectTrigger className="h-8 flex-1 text-sm">
<SelectValue placeholder={destinationLabel} /> {localDestination ? getDestinationLabel() : <span className="text-muted-foreground">{destinationLabel}</span>}
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent position="popper" sideOffset={4}>
{options.map((option) => ( {options.map((option) => (
<SelectItem key={option.value} value={option.value}> <SelectItem key={option.value} value={option.value}>
{option.label} {option.label}

View File

@ -90,7 +90,7 @@ export function LocationSwapSelectorConfigPanel({
} }
}, [config?.dataSource?.tableName, config?.dataSource?.type]); }, [config?.dataSource?.tableName, config?.dataSource?.type]);
// 코드 카테고리 로드 // 코드 카테고리 로드 (API가 없을 수 있으므로 에러 무시)
useEffect(() => { useEffect(() => {
const loadCodeCategories = async () => { const loadCodeCategories = async () => {
try { try {
@ -103,8 +103,11 @@ export function LocationSwapSelectorConfigPanel({
})) }))
); );
} }
} catch (error) { } catch (error: any) {
console.error("코드 카테고리 로드 실패:", error); // 404는 API가 없는 것이므로 무시
if (error?.response?.status !== 404) {
console.error("코드 카테고리 로드 실패:", error);
}
} }
}; };
loadCodeCategories(); loadCodeCategories();
@ -139,13 +142,83 @@ export function LocationSwapSelectorConfigPanel({
<SelectValue placeholder="선택" /> <SelectValue placeholder="선택" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="static"> ()</SelectItem> <SelectItem value="static"> (/ )</SelectItem>
<SelectItem value="table"></SelectItem> <SelectItem value="table"> </SelectItem>
<SelectItem value="code"> </SelectItem> <SelectItem value="code"> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
{/* 고정 옵션 설정 (type이 static일 때) */}
{(!config?.dataSource?.type || config?.dataSource?.type === "static") && (
<div className="space-y-3 rounded-md bg-amber-50 p-3 dark:bg-amber-950">
<h4 className="text-sm font-medium"> </h4>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-2">
<Label> 1 ()</Label>
<Input
value={config?.dataSource?.staticOptions?.[0]?.value || ""}
onChange={(e) => {
const options = config?.dataSource?.staticOptions || [];
const newOptions = [...options];
newOptions[0] = { ...newOptions[0], value: e.target.value };
handleChange("dataSource.staticOptions", newOptions);
}}
placeholder="예: pohang"
className="h-8 text-xs"
/>
</div>
<div className="space-y-2">
<Label> 1 ()</Label>
<Input
value={config?.dataSource?.staticOptions?.[0]?.label || ""}
onChange={(e) => {
const options = config?.dataSource?.staticOptions || [];
const newOptions = [...options];
newOptions[0] = { ...newOptions[0], label: e.target.value };
handleChange("dataSource.staticOptions", newOptions);
}}
placeholder="예: 포항"
className="h-8 text-xs"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-2">
<Label> 2 ()</Label>
<Input
value={config?.dataSource?.staticOptions?.[1]?.value || ""}
onChange={(e) => {
const options = config?.dataSource?.staticOptions || [];
const newOptions = [...options];
newOptions[1] = { ...newOptions[1], value: e.target.value };
handleChange("dataSource.staticOptions", newOptions);
}}
placeholder="예: gwangyang"
className="h-8 text-xs"
/>
</div>
<div className="space-y-2">
<Label> 2 ()</Label>
<Input
value={config?.dataSource?.staticOptions?.[1]?.label || ""}
onChange={(e) => {
const options = config?.dataSource?.staticOptions || [];
const newOptions = [...options];
newOptions[1] = { ...newOptions[1], label: e.target.value };
handleChange("dataSource.staticOptions", newOptions);
}}
placeholder="예: 광양"
className="h-8 text-xs"
/>
</div>
</div>
<p className="text-xs text-amber-700 dark:text-amber-300">
2 . (: 포항 )
</p>
</div>
)}
{/* 테이블 선택 (type이 table일 때) */} {/* 테이블 선택 (type이 table일 때) */}
{config?.dataSource?.type === "table" && ( {config?.dataSource?.type === "table" && (
<> <>
@ -298,14 +371,14 @@ export function LocationSwapSelectorConfigPanel({
<Label> ()</Label> <Label> ()</Label>
{tableColumns.length > 0 ? ( {tableColumns.length > 0 ? (
<Select <Select
value={config?.departureLabelField || ""} value={config?.departureLabelField || "__none__"}
onValueChange={(value) => handleChange("departureLabelField", value)} onValueChange={(value) => handleChange("departureLabelField", value === "__none__" ? "" : value)}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="컬럼 선택 (선택사항)" /> <SelectValue placeholder="컬럼 선택 (선택사항)" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value=""></SelectItem> <SelectItem value="__none__"></SelectItem>
{tableColumns.map((col) => ( {tableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}> <SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName} {col.columnLabel || col.columnName}
@ -325,14 +398,14 @@ export function LocationSwapSelectorConfigPanel({
<Label> ()</Label> <Label> ()</Label>
{tableColumns.length > 0 ? ( {tableColumns.length > 0 ? (
<Select <Select
value={config?.destinationLabelField || ""} value={config?.destinationLabelField || "__none__"}
onValueChange={(value) => handleChange("destinationLabelField", value)} onValueChange={(value) => handleChange("destinationLabelField", value === "__none__" ? "" : value)}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="컬럼 선택 (선택사항)" /> <SelectValue placeholder="컬럼 선택 (선택사항)" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value=""></SelectItem> <SelectItem value="__none__"></SelectItem>
{tableColumns.map((col) => ( {tableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}> <SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName} {col.columnLabel || col.columnName}

View File

@ -12,7 +12,28 @@ export class LocationSwapSelectorRenderer extends AutoRegisteringComponentRender
static componentDefinition = LocationSwapSelectorDefinition; static componentDefinition = LocationSwapSelectorDefinition;
render(): React.ReactElement { render(): React.ReactElement {
return <LocationSwapSelectorComponent {...this.props} />; const { component, formData, onFormDataChange, isDesignMode, style, ...restProps } = this.props;
// component.componentConfig에서 설정 가져오기
const componentConfig = component?.componentConfig || {};
console.log("[LocationSwapSelectorRenderer] render:", {
componentConfig,
formData,
isDesignMode
});
return (
<LocationSwapSelectorComponent
id={component?.id}
style={style}
isDesignMode={isDesignMode}
formData={formData}
onFormDataChange={onFormDataChange}
componentConfig={componentConfig}
{...restProps}
/>
);
} }
} }

View File

@ -20,12 +20,15 @@ export const LocationSwapSelectorDefinition = createComponentDefinition({
defaultConfig: { defaultConfig: {
// 데이터 소스 설정 // 데이터 소스 설정
dataSource: { dataSource: {
type: "table", // "table" | "code" | "static" type: "static", // "table" | "code" | "static"
tableName: "", // 장소 테이블명 tableName: "", // 장소 테이블명
valueField: "location_code", // 값 필드 valueField: "location_code", // 값 필드
labelField: "location_name", // 표시 필드 labelField: "location_name", // 표시 필드
codeCategory: "", // 코드 관리 카테고리 (type이 "code"일 때) codeCategory: "", // 코드 관리 카테고리 (type이 "code"일 때)
staticOptions: [], // 정적 옵션 (type이 "static"일 때) staticOptions: [
{ value: "pohang", label: "포항" },
{ value: "gwangyang", label: "광양" },
], // 정적 옵션 (type이 "static"일 때)
}, },
// 필드 매핑 // 필드 매핑
departureField: "departure", // 출발지 저장 필드 departureField: "departure", // 출발지 저장 필드

View File

@ -1,5 +1,4 @@
import React, { useState, useEffect, useRef, useMemo } from "react"; import React, { useState, useEffect, useRef, useMemo } from "react";
import { createPortal } from "react-dom";
import { filterDOMProps } from "@/lib/utils/domPropsFilter"; import { filterDOMProps } from "@/lib/utils/domPropsFilter";
import { useCodeOptions, useTableCodeCategory } from "@/hooks/queries/useCodes"; import { useCodeOptions, useTableCodeCategory } from "@/hooks/queries/useCodes";
import { cn } from "@/lib/registry/components/common/inputStyles"; import { cn } from "@/lib/registry/components/common/inputStyles";
@ -66,8 +65,6 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
}); });
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
// 드롭다운 위치 (Portal 렌더링용)
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0, width: 0 });
// webTypeConfig 또는 componentConfig 사용 (DynamicWebTypeRenderer 호환성) // webTypeConfig 또는 componentConfig 사용 (DynamicWebTypeRenderer 호환성)
const config = (props as any).webTypeConfig || componentConfig || {}; const config = (props as any).webTypeConfig || componentConfig || {};
@ -329,26 +326,9 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
}, [selectedValue, codeOptions, config.options]); }, [selectedValue, codeOptions, config.options]);
// 클릭 이벤트 핸들러 (React Query로 간소화) // 클릭 이벤트 핸들러 (React Query로 간소화)
// 드롭다운 위치 계산 함수
const updateDropdownPosition = () => {
if (selectRef.current) {
const rect = selectRef.current.getBoundingClientRect();
setDropdownPosition({
top: rect.bottom + window.scrollY,
left: rect.left + window.scrollX,
width: rect.width,
});
}
};
const handleToggle = () => { const handleToggle = () => {
if (isDesignMode) return; if (isDesignMode) return;
// 드롭다운 열기 전에 위치 계산
if (!isOpen) {
updateDropdownPosition();
}
// React Query가 자동으로 캐시 관리하므로 수동 새로고침 불필요 // React Query가 자동으로 캐시 관리하므로 수동 새로고침 불필요
setIsOpen(!isOpen); setIsOpen(!isOpen);
}; };
@ -470,13 +450,9 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
value={searchQuery || selectedLabel} value={searchQuery || selectedLabel}
onChange={(e) => { onChange={(e) => {
setSearchQuery(e.target.value); setSearchQuery(e.target.value);
updateDropdownPosition();
setIsOpen(true);
}}
onFocus={() => {
updateDropdownPosition();
setIsOpen(true); setIsOpen(true);
}} }}
onFocus={() => setIsOpen(true)}
placeholder="코드 또는 코드명 입력..." placeholder="코드 또는 코드명 입력..."
className={cn( className={cn(
"h-10 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 transition-all outline-none", "h-10 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 transition-all outline-none",
@ -485,16 +461,8 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
)} )}
readOnly={isDesignMode} readOnly={isDesignMode}
/> />
{/* Portal을 사용하여 드롭다운을 document.body에 렌더링 */} {isOpen && !isDesignMode && filteredOptions.length > 0 && (
{isOpen && !isDesignMode && filteredOptions.length > 0 && typeof document !== "undefined" && createPortal( <div className="absolute z-[99999] mt-1 max-h-60 w-full overflow-auto rounded-md border border-gray-300 bg-white shadow-lg">
<div
className="fixed z-[99999] max-h-60 overflow-auto rounded-md border border-gray-300 bg-white shadow-lg"
style={{
top: dropdownPosition.top,
left: dropdownPosition.left,
width: dropdownPosition.width,
}}
>
{filteredOptions.map((option, index) => ( {filteredOptions.map((option, index) => (
<div <div
key={`${option.value}-${index}`} key={`${option.value}-${index}`}
@ -510,8 +478,7 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
</div> </div>
</div> </div>
))} ))}
</div>, </div>
document.body
)} )}
</div> </div>
); );
@ -541,16 +508,8 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg> </svg>
</div> </div>
{/* Portal을 사용하여 드롭다운을 document.body에 렌더링 */} {isOpen && !isDesignMode && (
{isOpen && !isDesignMode && typeof document !== "undefined" && createPortal( <div className="absolute z-[99999] mt-1 max-h-60 w-full overflow-auto rounded-md border border-gray-300 bg-white shadow-lg">
<div
className="fixed z-[99999] max-h-60 overflow-auto rounded-md border border-gray-300 bg-white shadow-lg"
style={{
top: dropdownPosition.top,
left: dropdownPosition.left,
width: dropdownPosition.width,
}}
>
{isLoadingCodes ? ( {isLoadingCodes ? (
<div className="bg-white px-3 py-2 text-gray-900"> ...</div> <div className="bg-white px-3 py-2 text-gray-900"> ...</div>
) : allOptions.length > 0 ? ( ) : allOptions.length > 0 ? (
@ -566,8 +525,7 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
) : ( ) : (
<div className="bg-white px-3 py-2 text-gray-900"> </div> <div className="bg-white px-3 py-2 text-gray-900"> </div>
)} )}
</div>, </div>
document.body
)} )}
</div> </div>
); );
@ -632,13 +590,9 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
value={searchQuery} value={searchQuery}
onChange={(e) => { onChange={(e) => {
setSearchQuery(e.target.value); setSearchQuery(e.target.value);
updateDropdownPosition();
setIsOpen(true);
}}
onFocus={() => {
updateDropdownPosition();
setIsOpen(true); setIsOpen(true);
}} }}
onFocus={() => setIsOpen(true)}
placeholder={placeholder} placeholder={placeholder}
className={cn( className={cn(
"h-10 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 transition-all outline-none", "h-10 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 transition-all outline-none",
@ -647,16 +601,8 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
)} )}
readOnly={isDesignMode} readOnly={isDesignMode}
/> />
{/* Portal을 사용하여 드롭다운을 document.body에 렌더링 */} {isOpen && !isDesignMode && filteredOptions.length > 0 && (
{isOpen && !isDesignMode && filteredOptions.length > 0 && typeof document !== "undefined" && createPortal( <div className="absolute z-[99999] mt-1 max-h-60 w-full overflow-auto rounded-md border border-gray-300 bg-white shadow-lg">
<div
className="fixed z-[99999] max-h-60 overflow-auto rounded-md border border-gray-300 bg-white shadow-lg"
style={{
top: dropdownPosition.top,
left: dropdownPosition.left,
width: dropdownPosition.width,
}}
>
{filteredOptions.map((option, index) => ( {filteredOptions.map((option, index) => (
<div <div
key={`${option.value}-${index}`} key={`${option.value}-${index}`}
@ -674,8 +620,7 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
{option.label} {option.label}
</div> </div>
))} ))}
</div>, </div>
document.body
)} )}
</div> </div>
); );
@ -705,16 +650,8 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg> </svg>
</div> </div>
{/* Portal을 사용하여 드롭다운을 document.body에 렌더링 */} {isOpen && !isDesignMode && (
{isOpen && !isDesignMode && typeof document !== "undefined" && createPortal( <div className="absolute z-[99999] mt-1 w-full rounded-md border border-gray-300 bg-white shadow-lg">
<div
className="fixed z-[99999] rounded-md border border-gray-300 bg-white shadow-lg"
style={{
top: dropdownPosition.top,
left: dropdownPosition.left,
width: dropdownPosition.width,
}}
>
<input <input
type="text" type="text"
value={searchQuery} value={searchQuery}
@ -739,8 +676,7 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
</div> </div>
))} ))}
</div> </div>
</div>, </div>
document.body
)} )}
</div> </div>
); );
@ -757,12 +693,7 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
!isDesignMode && "hover:border-orange-400", !isDesignMode && "hover:border-orange-400",
isSelected && "ring-2 ring-orange-500", isSelected && "ring-2 ring-orange-500",
)} )}
onClick={() => { onClick={() => !isDesignMode && setIsOpen(true)}
if (!isDesignMode) {
updateDropdownPosition();
setIsOpen(true);
}
}}
style={{ style={{
pointerEvents: isDesignMode ? "none" : "auto", pointerEvents: isDesignMode ? "none" : "auto",
height: "100%" height: "100%"
@ -795,30 +726,22 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
<span className="text-gray-500">{placeholder}</span> <span className="text-gray-500">{placeholder}</span>
)} )}
</div> </div>
{/* Portal을 사용하여 드롭다운을 document.body에 렌더링 */} {isOpen && !isDesignMode && (
{isOpen && !isDesignMode && typeof document !== "undefined" && createPortal( <div className="absolute z-[99999] mt-1 max-h-60 w-full overflow-auto rounded-md border border-gray-300 bg-white shadow-lg">
<div
className="fixed z-[99999] max-h-60 overflow-auto rounded-md border border-gray-300 bg-white shadow-lg"
style={{
top: dropdownPosition.top,
left: dropdownPosition.left,
width: dropdownPosition.width,
}}
>
{(isLoadingCodes || isLoadingCategories) ? ( {(isLoadingCodes || isLoadingCategories) ? (
<div className="bg-white px-3 py-2 text-gray-900"> ...</div> <div className="bg-white px-3 py-2 text-gray-900"> ...</div>
) : allOptions.length > 0 ? ( ) : allOptions.length > 0 ? (
allOptions.map((option, index) => { allOptions.map((option, index) => {
const isOptionSelected = selectedValues.includes(option.value); const isSelected = selectedValues.includes(option.value);
return ( return (
<div <div
key={`${option.value}-${index}`} key={`${option.value}-${index}`}
className={cn( className={cn(
"cursor-pointer px-3 py-2 text-gray-900 hover:bg-gray-100", "cursor-pointer px-3 py-2 text-gray-900 hover:bg-gray-100",
isOptionSelected && "bg-blue-50 font-medium" isSelected && "bg-blue-50 font-medium"
)} )}
onClick={() => { onClick={() => {
const newVals = isOptionSelected const newVals = isSelected
? selectedValues.filter((v) => v !== option.value) ? selectedValues.filter((v) => v !== option.value)
: [...selectedValues, option.value]; : [...selectedValues, option.value];
setSelectedValues(newVals); setSelectedValues(newVals);
@ -831,7 +754,7 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <input
type="checkbox" type="checkbox"
checked={isOptionSelected} checked={isSelected}
onChange={() => {}} onChange={() => {}}
className="h-4 w-4" className="h-4 w-4"
/> />
@ -843,8 +766,7 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
) : ( ) : (
<div className="bg-white px-3 py-2 text-gray-900"> </div> <div className="bg-white px-3 py-2 text-gray-900"> </div>
)} )}
</div>, </div>
document.body
)} )}
</div> </div>
); );
@ -873,16 +795,8 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg> </svg>
</div> </div>
{/* Portal을 사용하여 드롭다운을 document.body에 렌더링 */} {isOpen && !isDesignMode && (
{isOpen && !isDesignMode && typeof document !== "undefined" && createPortal( <div className="absolute z-[99999] mt-1 max-h-60 w-full overflow-auto rounded-md border border-gray-300 bg-white shadow-lg">
<div
className="fixed z-[99999] max-h-60 overflow-auto rounded-md border border-gray-300 bg-white shadow-lg"
style={{
top: dropdownPosition.top,
left: dropdownPosition.left,
width: dropdownPosition.width,
}}
>
{isLoadingCodes ? ( {isLoadingCodes ? (
<div className="bg-white px-3 py-2 text-gray-900"> ...</div> <div className="bg-white px-3 py-2 text-gray-900"> ...</div>
) : allOptions.length > 0 ? ( ) : allOptions.length > 0 ? (
@ -898,8 +812,7 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
) : ( ) : (
<div className="bg-white px-3 py-2 text-gray-900"> </div> <div className="bg-white px-3 py-2 text-gray-900"> </div>
)} )}
</div>, </div>
document.body
)} )}
</div> </div>
); );

View File

@ -16,7 +16,7 @@ export type ButtonActionType =
| "edit" // 편집 | "edit" // 편집
| "copy" // 복사 (품목코드 초기화) | "copy" // 복사 (품목코드 초기화)
| "navigate" // 페이지 이동 | "navigate" // 페이지 이동
| "openModalWithData" // 🆕 데이터를 전달하면서 모달 열기 | "openModalWithData" // 데이터를 전달하면서 모달 열기
| "modal" // 모달 열기 | "modal" // 모달 열기
| "control" // 제어 흐름 | "control" // 제어 흐름
| "view_table_history" // 테이블 이력 보기 | "view_table_history" // 테이블 이력 보기
@ -24,10 +24,10 @@ export type ButtonActionType =
| "excel_upload" // 엑셀 업로드 | "excel_upload" // 엑셀 업로드
| "barcode_scan" // 바코드 스캔 | "barcode_scan" // 바코드 스캔
| "code_merge" // 코드 병합 | "code_merge" // 코드 병합
| "geolocation" // 위치정보 가져오기 | "empty_vehicle" // 공차등록 (위치 수집 + 상태 변경)
| "operation_control" // 운행알림 및 종료 (위치 수집 + 상태 변경 + 연속 추적)
| "swap_fields" // 필드 값 교환 (출발지 ↔ 목적지) | "swap_fields" // 필드 값 교환 (출발지 ↔ 목적지)
| "update_field" // 특정 필드 값 변경 (예: status를 active로) | "transferData"; // 데이터 전달 (컴포넌트 간 or 화면 간)
| "transferData"; // 🆕 데이터 전달 (컴포넌트 간 or 화면 간)
/** /**
* *
@ -104,6 +104,8 @@ export interface ButtonActionConfig {
geolocationTimeout?: number; // 타임아웃 (ms, 기본: 10000) geolocationTimeout?: number; // 타임아웃 (ms, 기본: 10000)
geolocationMaxAge?: number; // 캐시된 위치 최대 수명 (ms, 기본: 0) geolocationMaxAge?: number; // 캐시된 위치 최대 수명 (ms, 기본: 0)
geolocationAutoSave?: boolean; // 위치 가져온 후 자동 저장 여부 (기본: false) geolocationAutoSave?: boolean; // 위치 가져온 후 자동 저장 여부 (기본: false)
geolocationKeyField?: string; // DB UPDATE 시 WHERE 조건에 사용할 키 필드 (예: "user_id")
geolocationKeySourceField?: string; // 키 값 소스 (예: "__userId__" 또는 폼 필드명)
geolocationUpdateField?: boolean; // 위치정보와 함께 추가 필드 변경 여부 geolocationUpdateField?: boolean; // 위치정보와 함께 추가 필드 변경 여부
geolocationExtraTableName?: string; // 추가 필드 변경 대상 테이블 (다른 테이블 가능) geolocationExtraTableName?: string; // 추가 필드 변경 대상 테이블 (다른 테이블 가능)
geolocationExtraField?: string; // 추가로 변경할 필드명 (예: "status") geolocationExtraField?: string; // 추가로 변경할 필드명 (예: "status")
@ -111,6 +113,30 @@ export interface ButtonActionConfig {
geolocationExtraKeyField?: string; // 다른 테이블의 키 필드 (예: "vehicle_id") geolocationExtraKeyField?: string; // 다른 테이블의 키 필드 (예: "vehicle_id")
geolocationExtraKeySourceField?: string; // 현재 폼에서 키 값을 가져올 필드 (예: "vehicle_id") geolocationExtraKeySourceField?: string; // 현재 폼에서 키 값을 가져올 필드 (예: "vehicle_id")
// 🆕 두 번째 테이블 설정 (위치정보 + 상태변경을 각각 다른 테이블에)
geolocationSecondTableEnabled?: boolean; // 두 번째 테이블 사용 여부
geolocationSecondTableName?: string; // 두 번째 테이블명 (예: "vehicles")
geolocationSecondMode?: "update" | "insert"; // 작업 모드 (기본: update)
geolocationSecondField?: string; // 두 번째 테이블에서 변경할 필드명 (예: "status")
geolocationSecondValue?: string | number | boolean; // 두 번째 테이블에서 변경할 값 (예: "inactive")
geolocationSecondKeyField?: string; // 두 번째 테이블의 키 필드 (예: "id") - UPDATE 모드에서만 사용
geolocationSecondKeySourceField?: string; // 현재 폼에서 키 값을 가져올 필드 (예: "vehicle_id") - UPDATE 모드에서만 사용
geolocationSecondInsertFields?: Record<string, any>; // INSERT 모드에서 추가로 넣을 필드들
// 🆕 연속 위치 추적 설정 (update_field 액션의 updateWithTracking 옵션용)
trackingInterval?: number; // 위치 저장 주기 (ms, 기본: 10000 = 10초)
trackingTripIdField?: string; // 운행 ID를 저장할 필드명 (예: "trip_id")
trackingAutoGenerateTripId?: boolean; // 운행 ID 자동 생성 여부 (기본: true)
trackingDepartureField?: string; // 출발지 필드명 (formData에서 가져옴)
trackingArrivalField?: string; // 도착지 필드명 (formData에서 가져옴)
trackingVehicleIdField?: string; // 차량 ID 필드명 (formData에서 가져옴)
trackingStatusOnStart?: string; // 추적 시작 시 상태값 (예: "active")
trackingStatusOnStop?: string; // 추적 종료 시 상태값 (예: "completed")
trackingStatusField?: string; // 상태 필드명 (vehicles 테이블 등)
trackingStatusTableName?: string; // 상태 변경 대상 테이블명
trackingStatusKeyField?: string; // 상태 변경 키 필드 (예: "user_id")
trackingStatusKeySourceField?: string; // 키 값 소스 (예: "__userId__")
// 필드 값 교환 관련 (출발지 ↔ 목적지) // 필드 값 교환 관련 (출발지 ↔ 목적지)
swapFieldA?: string; // 교환할 첫 번째 필드명 (예: "departure") swapFieldA?: string; // 교환할 첫 번째 필드명 (예: "departure")
swapFieldB?: string; // 교환할 두 번째 필드명 (예: "destination") swapFieldB?: string; // 교환할 두 번째 필드명 (예: "destination")
@ -121,6 +147,21 @@ export interface ButtonActionConfig {
updateTargetValue?: string | number | boolean; // 변경할 값 (예: "active") updateTargetValue?: string | number | boolean; // 변경할 값 (예: "active")
updateAutoSave?: boolean; // 변경 후 자동 저장 여부 (기본: true) updateAutoSave?: boolean; // 변경 후 자동 저장 여부 (기본: true)
updateMultipleFields?: Array<{ field: string; value: string | number | boolean }>; // 여러 필드 동시 변경 updateMultipleFields?: Array<{ field: string; value: string | number | boolean }>; // 여러 필드 동시 변경
updateTableName?: string; // 대상 테이블명 (다른 테이블 UPDATE 시)
updateKeyField?: string; // 키 필드명 (WHERE 조건에 사용)
updateKeySourceField?: string; // 키 값 소스 (폼 필드명 또는 __userId__ 등 특수 키워드)
// 🆕 필드 값 변경 + 위치정보 수집 (update_field 액션에서 사용)
updateWithGeolocation?: boolean; // 위치정보도 함께 수집할지 여부
updateGeolocationLatField?: string; // 위도 저장 필드
updateGeolocationLngField?: string; // 경도 저장 필드
// 🆕 필드 값 변경 + 연속 위치 추적 (update_field 액션에서 사용)
updateWithTracking?: boolean; // 연속 위치 추적 사용 여부
updateTrackingMode?: "start" | "stop"; // 추적 모드 (시작/종료)
updateTrackingInterval?: number; // 위치 저장 주기 (ms, 기본: 10000)
updateGeolocationAccuracyField?: string; // 정확도 저장 필드 (선택)
updateGeolocationTimestampField?: string; // 타임스탬프 저장 필드 (선택)
// 편집 관련 (수주관리 등 그룹별 다중 레코드 편집) // 편집 관련 (수주관리 등 그룹별 다중 레코드 편집)
editMode?: "modal" | "navigate" | "inline"; // 편집 모드 editMode?: "modal" | "navigate" | "inline"; // 편집 모드
@ -220,6 +261,44 @@ export interface ButtonActionContext {
splitPanelParentData?: Record<string, any>; splitPanelParentData?: Record<string, any>;
} }
/**
* 🆕
* :
* - __userId__ : 로그인한 ID
* - __userName__ : 로그인한
* - __companyCode__ : 로그인한
* - __screenId__ : 현재 ID
* - __tableName__ : 현재
*/
export function resolveSpecialKeyword(
sourceField: string | undefined,
context: ButtonActionContext
): any {
if (!sourceField) return undefined;
// 특수 키워드 처리
switch (sourceField) {
case "__userId__":
console.log("🔑 특수 키워드 변환: __userId__ →", context.userId);
return context.userId;
case "__userName__":
console.log("🔑 특수 키워드 변환: __userName__ →", context.userName);
return context.userName;
case "__companyCode__":
console.log("🔑 특수 키워드 변환: __companyCode__ →", context.companyCode);
return context.companyCode;
case "__screenId__":
console.log("🔑 특수 키워드 변환: __screenId__ →", context.screenId);
return context.screenId;
case "__tableName__":
console.log("🔑 특수 키워드 변환: __tableName__ →", context.tableName);
return context.tableName;
default:
// 일반 폼 데이터에서 가져오기
return context.formData?.[sourceField];
}
}
/** /**
* *
*/ */
@ -274,11 +353,14 @@ export class ButtonActionExecutor {
case "transferData": case "transferData":
return await this.handleTransferData(config, context); return await this.handleTransferData(config, context);
case "geolocation": case "empty_vehicle":
return await this.handleGeolocation(config, context); return await this.handleEmptyVehicle(config, context);
case "update_field": case "operation_control":
return await this.handleUpdateField(config, context); return await this.handleOperationControl(config, context);
case "swap_fields":
return await this.handleSwapFields(config, context);
default: default:
console.warn(`지원되지 않는 액션 타입: ${config.type}`); console.warn(`지원되지 않는 액션 타입: ${config.type}`);
@ -3253,6 +3335,258 @@ export class ButtonActionExecutor {
} }
} }
// 🆕 연속 위치 추적 상태 저장 (전역)
private static trackingIntervalId: NodeJS.Timeout | null = null;
private static currentTripId: string | null = null;
private static trackingContext: ButtonActionContext | null = null;
private static trackingConfig: ButtonActionConfig | null = null;
/**
*
*/
private static async handleTrackingStart(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
try {
console.log("🚀 [handleTrackingStart] 위치 추적 시작:", { config, context });
// 이미 추적 중인지 확인
if (this.trackingIntervalId) {
toast.warning("이미 위치 추적이 진행 중입니다.");
return false;
}
// 위치 권한 확인
if (!navigator.geolocation) {
toast.error("이 브라우저는 위치 정보를 지원하지 않습니다.");
return false;
}
// Trip ID 생성
const tripId = config.trackingAutoGenerateTripId !== false
? `TRIP_${Date.now()}_${context.userId || "unknown"}`
: context.formData?.[config.trackingTripIdField || "trip_id"] || `TRIP_${Date.now()}`;
this.currentTripId = tripId;
this.trackingContext = context;
this.trackingConfig = config;
// 출발지/도착지 정보
const departure = context.formData?.[config.trackingDepartureField || "departure"] || null;
const arrival = context.formData?.[config.trackingArrivalField || "arrival"] || null;
const departureName = context.formData?.["departure_name"] || null;
const destinationName = context.formData?.["destination_name"] || null;
const vehicleId = context.formData?.[config.trackingVehicleIdField || "vehicle_id"] || null;
console.log("📍 [handleTrackingStart] 운행 정보:", {
tripId,
departure,
arrival,
departureName,
destinationName,
vehicleId,
});
// 상태 변경 (vehicles 테이블 등)
if (config.trackingStatusOnStart && config.trackingStatusField) {
try {
const { apiClient } = await import("@/lib/api/client");
const statusTableName = config.trackingStatusTableName || context.tableName;
const keyField = config.trackingStatusKeyField || "user_id";
const keyValue = resolveSpecialKeyword(config.trackingStatusKeySourceField || "__userId__", context);
if (keyValue) {
await apiClient.put(`/dynamic-form/update-field`, {
tableName: statusTableName,
keyField: keyField,
keyValue: keyValue,
updateField: config.trackingStatusField,
updateValue: config.trackingStatusOnStart,
});
console.log("✅ 상태 변경 완료:", config.trackingStatusOnStart);
}
} catch (statusError) {
console.warn("⚠️ 상태 변경 실패:", statusError);
}
}
// 첫 번째 위치 저장
await this.saveLocationToHistory(tripId, departure, arrival, departureName, destinationName, vehicleId);
// 주기적 위치 저장 시작
const interval = config.trackingInterval || 10000; // 기본 10초
this.trackingIntervalId = setInterval(async () => {
await this.saveLocationToHistory(tripId, departure, arrival, departureName, destinationName, vehicleId);
}, interval);
toast.success(config.successMessage || `위치 추적이 시작되었습니다. (${interval / 1000}초 간격)`);
// 추적 시작 이벤트 발생 (UI 업데이트용)
window.dispatchEvent(new CustomEvent("trackingStarted", {
detail: { tripId, interval }
}));
return true;
} catch (error: any) {
console.error("❌ 위치 추적 시작 실패:", error);
toast.error(config.errorMessage || "위치 추적 시작 중 오류가 발생했습니다.");
return false;
}
}
/**
*
*/
private static async handleTrackingStop(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
try {
console.log("🛑 [handleTrackingStop] 위치 추적 종료:", { config, context });
// 추적 중인지 확인
if (!this.trackingIntervalId) {
toast.warning("진행 중인 위치 추적이 없습니다.");
return false;
}
// 타이머 정리
clearInterval(this.trackingIntervalId);
this.trackingIntervalId = null;
const tripId = this.currentTripId;
// 마지막 위치 저장 (trip_status를 completed로)
const departure = this.trackingContext?.formData?.[this.trackingConfig?.trackingDepartureField || "departure"] || null;
const arrival = this.trackingContext?.formData?.[this.trackingConfig?.trackingArrivalField || "arrival"] || null;
const departureName = this.trackingContext?.formData?.["departure_name"] || null;
const destinationName = this.trackingContext?.formData?.["destination_name"] || null;
const vehicleId = this.trackingContext?.formData?.[this.trackingConfig?.trackingVehicleIdField || "vehicle_id"] || null;
await this.saveLocationToHistory(tripId, departure, arrival, departureName, destinationName, vehicleId, "completed");
// 상태 변경 (vehicles 테이블 등)
const effectiveConfig = config.trackingStatusOnStop ? config : this.trackingConfig;
const effectiveContext = context.userId ? context : this.trackingContext;
if (effectiveConfig?.trackingStatusOnStop && effectiveConfig?.trackingStatusField && effectiveContext) {
try {
const { apiClient } = await import("@/lib/api/client");
const statusTableName = effectiveConfig.trackingStatusTableName || effectiveContext.tableName;
const keyField = effectiveConfig.trackingStatusKeyField || "user_id";
const keyValue = resolveSpecialKeyword(effectiveConfig.trackingStatusKeySourceField || "__userId__", effectiveContext);
if (keyValue) {
await apiClient.put(`/dynamic-form/update-field`, {
tableName: statusTableName,
keyField: keyField,
keyValue: keyValue,
updateField: effectiveConfig.trackingStatusField,
updateValue: effectiveConfig.trackingStatusOnStop,
});
console.log("✅ 상태 변경 완료:", effectiveConfig.trackingStatusOnStop);
}
} catch (statusError) {
console.warn("⚠️ 상태 변경 실패:", statusError);
}
}
// 컨텍스트 정리
this.currentTripId = null;
this.trackingContext = null;
this.trackingConfig = null;
toast.success(config.successMessage || "위치 추적이 종료되었습니다.");
// 추적 종료 이벤트 발생 (UI 업데이트용)
window.dispatchEvent(new CustomEvent("trackingStopped", {
detail: { tripId }
}));
// 화면 새로고침
context.onRefresh?.();
return true;
} catch (error: any) {
console.error("❌ 위치 추적 종료 실패:", error);
toast.error(config.errorMessage || "위치 추적 종료 중 오류가 발생했습니다.");
return false;
}
}
/**
* ( )
*/
private static async saveLocationToHistory(
tripId: string | null,
departure: string | null,
arrival: string | null,
departureName: string | null,
destinationName: string | null,
vehicleId: number | null,
tripStatus: string = "active"
): Promise<void> {
return new Promise((resolve, reject) => {
navigator.geolocation.getCurrentPosition(
async (position) => {
try {
const { apiClient } = await import("@/lib/api/client");
const locationData = {
latitude: position.coords.latitude,
longitude: position.coords.longitude,
accuracy: position.coords.accuracy,
altitude: position.coords.altitude,
speed: position.coords.speed,
heading: position.coords.heading,
tripId,
tripStatus,
departure,
arrival,
departureName,
destinationName,
recordedAt: new Date(position.timestamp).toISOString(),
vehicleId,
};
console.log("📍 [saveLocationToHistory] 위치 저장:", locationData);
const response = await apiClient.post(`/dynamic-form/location-history`, locationData);
if (response.data?.success) {
console.log("✅ 위치 이력 저장 성공:", response.data.data);
} else {
console.warn("⚠️ 위치 이력 저장 실패:", response.data);
}
resolve();
} catch (error) {
console.error("❌ 위치 이력 저장 오류:", error);
reject(error);
}
},
(error) => {
console.error("❌ 위치 획득 실패:", error.message);
reject(error);
},
{
enableHighAccuracy: true,
timeout: 10000,
maximumAge: 0,
}
);
});
}
/**
* ( )
*/
static isTracking(): boolean {
return this.trackingIntervalId !== null;
}
/**
* Trip ID ( )
*/
static getCurrentTripId(): string | null {
return this.currentTripId;
}
/** /**
* ( ) * ( )
*/ */
@ -3350,9 +3684,10 @@ export class ButtonActionExecutor {
} }
/** /**
* *
* - + (: status inactive)
*/ */
private static async handleGeolocation(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> { private static async handleEmptyVehicle(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
try { try {
console.log("📍 위치정보 가져오기 액션 실행:", { config, context }); console.log("📍 위치정보 가져오기 액션 실행:", { config, context });
@ -3363,41 +3698,30 @@ export class ButtonActionExecutor {
} }
// 위도/경도 저장 필드 확인 // 위도/경도 저장 필드 확인
const latField = config.geolocationLatField; const latField = config.geolocationLatField || "latitude";
const lngField = config.geolocationLngField; const lngField = config.geolocationLngField || "longitude";
if (!latField || !lngField) {
toast.error("위도/경도 저장 필드가 설정되지 않았습니다.");
return false;
}
// 로딩 토스트 표시 // 로딩 토스트 표시
const loadingToastId = toast.loading("위치 정보를 가져오는 중..."); const loadingToastId = toast.loading("위치 정보를 가져오는 중...");
// Geolocation 옵션 설정 // Geolocation 옵션 설정
const options: PositionOptions = { const options: PositionOptions = {
enableHighAccuracy: config.geolocationHighAccuracy !== false, // 기본 true enableHighAccuracy: config.geolocationHighAccuracy !== false,
timeout: config.geolocationTimeout || 10000, // 기본 10초 timeout: config.geolocationTimeout || 10000,
maximumAge: config.geolocationMaxAge || 0, // 기본 0 (항상 새로운 위치) maximumAge: config.geolocationMaxAge || 0,
}; };
// 위치 정보 가져오기 (Promise로 래핑) // 위치 정보 가져오기
const position = await new Promise<GeolocationPosition>((resolve, reject) => { const position = await new Promise<GeolocationPosition>((resolve, reject) => {
navigator.geolocation.getCurrentPosition(resolve, reject, options); navigator.geolocation.getCurrentPosition(resolve, reject, options);
}); });
// 로딩 토스트 제거
toast.dismiss(loadingToastId); toast.dismiss(loadingToastId);
const { latitude, longitude, accuracy, altitude, heading, speed } = position.coords; const { latitude, longitude, accuracy } = position.coords;
const timestamp = new Date(position.timestamp); const timestamp = new Date(position.timestamp);
console.log("📍 위치정보 획득 성공:", { console.log("📍 위치정보 획득 성공:", { latitude, longitude, accuracy });
latitude,
longitude,
accuracy,
timestamp: timestamp.toISOString(),
});
// 폼 데이터 업데이트 // 폼 데이터 업데이트
const updates: Record<string, any> = { const updates: Record<string, any> = {
@ -3405,7 +3729,6 @@ export class ButtonActionExecutor {
[lngField]: longitude, [lngField]: longitude,
}; };
// 선택적 필드들
if (config.geolocationAccuracyField && accuracy !== null) { if (config.geolocationAccuracyField && accuracy !== null) {
updates[config.geolocationAccuracyField] = accuracy; updates[config.geolocationAccuracyField] = accuracy;
} }
@ -3413,101 +3736,71 @@ export class ButtonActionExecutor {
updates[config.geolocationTimestampField] = timestamp.toISOString(); updates[config.geolocationTimestampField] = timestamp.toISOString();
} }
// 🆕 추가 필드 변경 (위치정보 + 상태변경) // onFormDataChange로 폼 업데이트
let extraTableUpdated = false;
if (config.geolocationUpdateField && config.geolocationExtraField && config.geolocationExtraValue !== undefined) {
const extraTableName = config.geolocationExtraTableName;
const currentTableName = config.geolocationTableName || context.tableName;
// 다른 테이블에 UPDATE하는 경우
if (extraTableName && extraTableName !== currentTableName) {
console.log("📍 다른 테이블 필드 변경:", {
targetTable: extraTableName,
field: config.geolocationExtraField,
value: config.geolocationExtraValue,
keyField: config.geolocationExtraKeyField,
keySourceField: config.geolocationExtraKeySourceField,
});
// 키 값 가져오기
const keyValue = context.formData?.[config.geolocationExtraKeySourceField || ""];
if (keyValue && config.geolocationExtraKeyField) {
try {
// 다른 테이블 UPDATE API 호출
const { apiClient } = await import("@/lib/api/client");
const response = await apiClient.put(`/dynamic-form/update-field`, {
tableName: extraTableName,
keyField: config.geolocationExtraKeyField,
keyValue: keyValue,
updateField: config.geolocationExtraField,
updateValue: config.geolocationExtraValue,
});
if (response.data?.success) {
extraTableUpdated = true;
console.log("✅ 다른 테이블 UPDATE 성공:", response.data);
} else {
console.error("❌ 다른 테이블 UPDATE 실패:", response.data);
toast.error(`${extraTableName} 테이블 업데이트에 실패했습니다.`);
}
} catch (apiError) {
console.error("❌ 다른 테이블 UPDATE API 오류:", apiError);
toast.error(`${extraTableName} 테이블 업데이트 중 오류가 발생했습니다.`);
}
} else {
console.warn("⚠️ 키 값이 없어서 다른 테이블 UPDATE를 건너뜁니다:", {
keySourceField: config.geolocationExtraKeySourceField,
keyValue,
});
}
} else {
// 같은 테이블 (현재 폼 데이터에 추가)
updates[config.geolocationExtraField] = config.geolocationExtraValue;
console.log("📍 같은 테이블 추가 필드 변경:", {
field: config.geolocationExtraField,
value: config.geolocationExtraValue,
});
}
}
// formData 업데이트
if (context.onFormDataChange) { if (context.onFormDataChange) {
Object.entries(updates).forEach(([field, value]) => { Object.entries(updates).forEach(([field, value]) => {
context.onFormDataChange?.(field, value); context.onFormDataChange!(field, value);
}); });
} }
// 성공 메시지 생성 // 🆕 자동 저장 옵션이 활성화된 경우 DB UPDATE
let successMsg = if (config.geolocationAutoSave) {
config.successMessage || const keyField = config.geolocationKeyField || "user_id";
`위치 정보를 가져왔습니다.\n위도: ${latitude.toFixed(6)}, 경도: ${longitude.toFixed(6)}`; const keySourceField = config.geolocationKeySourceField || "__userId__";
const keyValue = resolveSpecialKeyword(keySourceField, context);
const targetTableName = config.geolocationTableName || context.tableName;
// 추가 필드 변경이 있으면 메시지에 포함 if (keyValue && targetTableName) {
if (config.geolocationUpdateField && config.geolocationExtraField) { try {
if (extraTableUpdated) { const { apiClient } = await import("@/lib/api/client");
successMsg += `\n[${config.geolocationExtraTableName}] ${config.geolocationExtraField}: ${config.geolocationExtraValue}`;
} else if ( // 위치 정보 필드들 업데이트 (위도, 경도, 정확도, 타임스탬프)
!config.geolocationExtraTableName || const fieldsToUpdate = { ...updates };
config.geolocationExtraTableName === (config.geolocationTableName || context.tableName)
) { // formData에서 departure, arrival만 포함 (테이블에 있을 가능성 높은 필드만)
successMsg += `\n${config.geolocationExtraField}: ${config.geolocationExtraValue}`; if (context.formData?.departure) fieldsToUpdate.departure = context.formData.departure;
} if (context.formData?.arrival) fieldsToUpdate.arrival = context.formData.arrival;
}
// 추가 필드 변경 (status 등)
// 성공 메시지 표시 if (config.geolocationExtraField && config.geolocationExtraValue !== undefined) {
toast.success(successMsg); fieldsToUpdate[config.geolocationExtraField] = config.geolocationExtraValue;
}
// 자동 저장 옵션이 활성화된 경우
if (config.geolocationAutoSave && context.onSave) { console.log("📍 DB UPDATE 시작:", { targetTableName, keyField, keyValue, fieldsToUpdate });
console.log("📍 위치정보 자동 저장 실행");
try { // 각 필드를 개별적으로 UPDATE (에러 무시)
await context.onSave(); let successCount = 0;
toast.success("위치 정보가 저장되었습니다."); for (const [field, value] of Object.entries(fieldsToUpdate)) {
} catch (saveError) { try {
console.error("❌ 위치정보 자동 저장 실패:", saveError); const response = await apiClient.put(`/dynamic-form/update-field`, {
toast.error("위치 정보 저장에 실패했습니다."); tableName: targetTableName,
keyField,
keyValue,
updateField: field,
updateValue: value,
});
if (response.data?.success) {
successCount++;
}
} catch {
// 컬럼이 없으면 조용히 무시 (에러 로그 안 찍음)
}
}
console.log(`📍 DB UPDATE 완료: ${successCount}/${Object.keys(fieldsToUpdate).length} 필드 저장됨`);
toast.success(config.successMessage || "위치 정보가 저장되었습니다.");
} catch (saveError) {
console.error("❌ 위치정보 자동 저장 실패:", saveError);
toast.error("위치 정보 저장에 실패했습니다.");
return false;
}
} else {
console.warn("⚠️ 키 값 또는 테이블명이 없어서 자동 저장을 건너뜁니다:", { keyValue, targetTableName });
toast.success(config.successMessage || `위치: ${latitude.toFixed(6)}, ${longitude.toFixed(6)}`);
} }
} else {
// 자동 저장 없이 성공 메시지만
toast.success(config.successMessage || `위치: ${latitude.toFixed(6)}, ${longitude.toFixed(6)}`);
} }
return true; return true;
@ -3539,12 +3832,91 @@ export class ButtonActionExecutor {
} }
/** /**
* (: status를 active로 ) * (: 출발지 )
*/ */
private static async handleUpdateField(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> { private static async handleSwapFields(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
try {
console.log("🔄 필드 값 교환 액션 실행:", { config, context });
const { formData, onFormDataChange } = context;
// 교환할 필드 확인
const fieldA = config.swapFieldA;
const fieldB = config.swapFieldB;
if (!fieldA || !fieldB) {
toast.error("교환할 필드가 설정되지 않았습니다.");
return false;
}
// 현재 값 가져오기
const valueA = formData?.[fieldA];
const valueB = formData?.[fieldB];
console.log("🔄 교환 전:", { [fieldA]: valueA, [fieldB]: valueB });
// 값 교환
if (onFormDataChange) {
onFormDataChange(fieldA, valueB);
onFormDataChange(fieldB, valueA);
}
// 관련 필드도 함께 교환 (예: 위도/경도)
if (config.swapRelatedFields && config.swapRelatedFields.length > 0) {
for (const related of config.swapRelatedFields) {
const relatedValueA = formData?.[related.fieldA];
const relatedValueB = formData?.[related.fieldB];
if (onFormDataChange) {
onFormDataChange(related.fieldA, relatedValueB);
onFormDataChange(related.fieldB, relatedValueA);
}
}
}
console.log("🔄 교환 후:", { [fieldA]: valueB, [fieldB]: valueA });
toast.success(config.successMessage || "값이 교환되었습니다.");
return true;
} catch (error) {
console.error("❌ 필드 값 교환 오류:", error);
toast.error(config.errorMessage || "값 교환 중 오류가 발생했습니다.");
return false;
}
}
/**
* (: status를 active로 )
* 🆕
* 🆕
*/
/**
*
* - + + (/)
*/
private static async handleOperationControl(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
try { try {
console.log("🔄 필드 값 변경 액션 실행:", { config, context }); console.log("🔄 필드 값 변경 액션 실행:", { config, context });
// 🆕 연속 위치 추적 모드 처리
if (config.updateWithTracking) {
const trackingConfig: ButtonActionConfig = {
...config,
trackingInterval: config.updateTrackingInterval || config.trackingInterval || 10000,
trackingStatusField: config.updateTargetField,
trackingStatusTableName: config.updateTableName || context.tableName,
trackingStatusKeyField: config.updateKeyField,
trackingStatusKeySourceField: config.updateKeySourceField,
};
if (config.updateTrackingMode === "start") {
trackingConfig.trackingStatusOnStart = config.updateTargetValue as string;
return await this.handleTrackingStart(trackingConfig, context);
} else if (config.updateTrackingMode === "stop") {
trackingConfig.trackingStatusOnStop = config.updateTargetValue as string;
return await this.handleTrackingStop(trackingConfig, context);
}
}
const { formData, tableName, onFormDataChange, onSave } = context; const { formData, tableName, onFormDataChange, onSave } = context;
// 변경할 필드 확인 // 변경할 필드 확인
@ -3553,7 +3925,7 @@ export class ButtonActionExecutor {
const multipleFields = config.updateMultipleFields || []; const multipleFields = config.updateMultipleFields || [];
// 단일 필드 변경이나 다중 필드 변경 중 하나는 있어야 함 // 단일 필드 변경이나 다중 필드 변경 중 하나는 있어야 함
if (!targetField && multipleFields.length === 0) { if (!targetField && multipleFields.length === 0 && !config.updateWithGeolocation) {
toast.error("변경할 필드가 설정되지 않았습니다."); toast.error("변경할 필드가 설정되지 않았습니다.");
return false; return false;
} }
@ -3580,6 +3952,69 @@ export class ButtonActionExecutor {
updates[field] = value; updates[field] = value;
}); });
// 🆕 위치정보 수집 (updateWithGeolocation이 true인 경우)
if (config.updateWithGeolocation) {
const latField = config.updateGeolocationLatField;
const lngField = config.updateGeolocationLngField;
if (!latField || !lngField) {
toast.error("위도/경도 저장 필드가 설정되지 않았습니다.");
return false;
}
// 브라우저 Geolocation API 지원 확인
if (!navigator.geolocation) {
toast.error("이 브라우저는 위치정보를 지원하지 않습니다.");
return false;
}
// 로딩 토스트 표시
const loadingToastId = toast.loading("위치 정보를 가져오는 중...");
try {
// 위치 정보 가져오기
const position = await new Promise<GeolocationPosition>((resolve, reject) => {
navigator.geolocation.getCurrentPosition(resolve, reject, {
enableHighAccuracy: true,
timeout: 10000,
maximumAge: 0,
});
});
toast.dismiss(loadingToastId);
const { latitude, longitude, accuracy } = position.coords;
const timestamp = new Date(position.timestamp);
console.log("📍 위치정보 획득:", { latitude, longitude, accuracy });
// 위치정보를 updates에 추가
updates[latField] = latitude;
updates[lngField] = longitude;
if (config.updateGeolocationAccuracyField && accuracy !== null) {
updates[config.updateGeolocationAccuracyField] = accuracy;
}
if (config.updateGeolocationTimestampField) {
updates[config.updateGeolocationTimestampField] = timestamp.toISOString();
}
} catch (geoError: any) {
toast.dismiss(loadingToastId);
// GeolocationPositionError 처리
if (geoError.code === 1) {
toast.error("위치 정보 접근이 거부되었습니다.");
} else if (geoError.code === 2) {
toast.error("위치 정보를 사용할 수 없습니다.");
} else if (geoError.code === 3) {
toast.error("위치 정보 요청 시간이 초과되었습니다.");
} else {
toast.error("위치 정보를 가져오는 중 오류가 발생했습니다.");
}
return false;
}
}
console.log("🔄 변경할 필드들:", updates); console.log("🔄 변경할 필드들:", updates);
// formData 업데이트 // formData 업데이트
@ -3593,6 +4028,67 @@ export class ButtonActionExecutor {
const autoSave = config.updateAutoSave !== false; const autoSave = config.updateAutoSave !== false;
if (autoSave) { if (autoSave) {
// 🆕 키 필드 설정이 있는 경우 (특수 키워드 지원) - 직접 DB UPDATE
const keyField = config.updateKeyField;
const keySourceField = config.updateKeySourceField;
const targetTableName = config.updateTableName || tableName;
if (keyField && keySourceField) {
// 특수 키워드 변환 (예: __userId__ → 실제 사용자 ID)
const keyValue = resolveSpecialKeyword(keySourceField, context);
console.log("🔄 필드 값 변경 - 키 필드 사용:", {
targetTable: targetTableName,
keyField,
keySourceField,
keyValue,
updates,
});
if (!keyValue) {
console.warn("⚠️ 키 값이 없어서 업데이트를 건너뜁니다:", { keySourceField, keyValue });
toast.error("레코드를 식별할 키 값이 없습니다.");
return false;
}
try {
// 각 필드에 대해 개별 UPDATE 호출
const { apiClient } = await import("@/lib/api/client");
for (const [field, value] of Object.entries(updates)) {
console.log(`🔄 DB UPDATE: ${targetTableName}.${field} = ${value} WHERE ${keyField} = ${keyValue}`);
const response = await apiClient.put(`/dynamic-form/update-field`, {
tableName: targetTableName,
keyField: keyField,
keyValue: keyValue,
updateField: field,
updateValue: value,
});
if (!response.data?.success) {
console.error(`${field} 업데이트 실패:`, response.data);
toast.error(`${field} 업데이트에 실패했습니다.`);
return false;
}
}
console.log("✅ 모든 필드 업데이트 성공");
toast.success(config.successMessage || "상태가 변경되었습니다.");
// 테이블 새로고침 이벤트 발생
window.dispatchEvent(new CustomEvent("refreshTableData", {
detail: { tableName: targetTableName }
}));
return true;
} catch (apiError) {
console.error("❌ 필드 값 변경 API 호출 실패:", apiError);
toast.error(config.errorMessage || "상태 변경 중 오류가 발생했습니다.");
return false;
}
}
// onSave 콜백이 있으면 사용 // onSave 콜백이 있으면 사용
if (onSave) { if (onSave) {
console.log("🔄 필드 값 변경 후 자동 저장 (onSave 콜백)"); console.log("🔄 필드 값 변경 후 자동 저장 (onSave 콜백)");
@ -3607,7 +4103,7 @@ export class ButtonActionExecutor {
} }
} }
// API를 통한 직접 저장 // API를 통한 직접 저장 (기존 방식: formData에 PK가 있는 경우)
if (tableName && formData) { if (tableName && formData) {
console.log("🔄 필드 값 변경 후 자동 저장 (API 직접 호출)"); console.log("🔄 필드 값 변경 후 자동 저장 (API 직접 호출)");
try { try {
@ -3616,7 +4112,7 @@ export class ButtonActionExecutor {
const pkValue = formData[pkField] || formData.id; const pkValue = formData[pkField] || formData.id;
if (!pkValue) { if (!pkValue) {
toast.error("레코드 ID를 찾을 수 없습니다."); toast.error("레코드 ID를 찾을 수 없습니다. 키 필드를 설정해주세요.");
return false; return false;
} }
@ -3731,6 +4227,12 @@ export const DEFAULT_BUTTON_ACTIONS: Record<ButtonActionType, Partial<ButtonActi
type: "edit", type: "edit",
successMessage: "편집되었습니다.", successMessage: "편집되었습니다.",
}, },
copy: {
type: "copy",
confirmMessage: "복사하시겠습니까?",
successMessage: "복사되었습니다.",
errorMessage: "복사 중 오류가 발생했습니다.",
},
control: { control: {
type: "control", type: "control",
}, },
@ -3769,21 +4271,36 @@ export const DEFAULT_BUTTON_ACTIONS: Record<ButtonActionType, Partial<ButtonActi
successMessage: "데이터가 전달되었습니다.", successMessage: "데이터가 전달되었습니다.",
errorMessage: "데이터 전달 중 오류가 발생했습니다.", errorMessage: "데이터 전달 중 오류가 발생했습니다.",
}, },
geolocation: { empty_vehicle: {
type: "geolocation", type: "empty_vehicle",
geolocationHighAccuracy: true, geolocationHighAccuracy: true,
geolocationTimeout: 10000, geolocationTimeout: 10000,
geolocationMaxAge: 0, geolocationMaxAge: 0,
geolocationAutoSave: false, geolocationLatField: "latitude",
confirmMessage: "현재 위치 정보를 가져오시겠습니까?", geolocationLngField: "longitude",
successMessage: "위치 정보를 가져왔습니다.", geolocationAutoSave: true,
errorMessage: "위치 정보를 가져오는 중 오류가 발생했습니다.", geolocationKeyField: "user_id",
geolocationKeySourceField: "__userId__",
geolocationExtraField: "status",
geolocationExtraValue: "inactive",
successMessage: "공차등록이 완료되었습니다.",
errorMessage: "공차등록 중 오류가 발생했습니다.",
}, },
update_field: { operation_control: {
type: "update_field", type: "operation_control",
updateAutoSave: true, updateAutoSave: true,
confirmMessage: "상태를 변경하시겠습니까?", updateWithGeolocation: true,
successMessage: "상태가 변경되었습니다.", updateGeolocationLatField: "latitude",
errorMessage: "상태 변경 중 오류가 발생했습니다.", updateGeolocationLngField: "longitude",
updateKeyField: "user_id",
updateKeySourceField: "__userId__",
confirmMessage: "운행 상태를 변경하시겠습니까?",
successMessage: "운행 상태가 변경되었습니다.",
errorMessage: "운행 상태 변경 중 오류가 발생했습니다.",
},
swap_fields: {
type: "swap_fields",
successMessage: "필드 값이 교환되었습니다.",
errorMessage: "필드 값 교환 중 오류가 발생했습니다.",
}, },
}; };