Compare commits

..

No commits in common. "52c7391cf5059d796b42cbd857adef0db7eaa6cd" and "d3a3237e7a5d978f003a3446bdf51ee40ceb7575" have entirely different histories.

60 changed files with 1250 additions and 5898 deletions

File diff suppressed because it is too large Load Diff

View File

@ -10,8 +10,3 @@ BOOKING_DATA_SOURCE=file
MAINTENANCE_DATA_SOURCE=memory
DOCUMENT_DATA_SOURCE=memory
# OpenWeatherMap API 키 추가 (실시간 날씨)
# https://openweathermap.org/api 에서 무료 가입 후 발급
OPENWEATHER_API_KEY=your_openweathermap_api_key_here

View File

@ -55,8 +55,7 @@ import riskAlertRoutes from "./routes/riskAlertRoutes"; // 리스크/알림 관
import todoRoutes from "./routes/todoRoutes"; // To-Do 관리
import bookingRoutes from "./routes/bookingRoutes"; // 예약 요청 관리
import mapDataRoutes from "./routes/mapDataRoutes"; // 지도 데이터 관리
import yardLayoutRoutes from "./routes/yardLayoutRoutes"; // 야드 관리 3D
import materialRoutes from "./routes/materialRoutes"; // 자재 관리
import warehouseRoutes from "./routes/warehouseRoutes"; // 창고 관리
import { BatchSchedulerService } from "./services/batchSchedulerService";
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
@ -206,8 +205,7 @@ app.use("/api/risk-alerts", riskAlertRoutes); // 리스크/알림 관리
app.use("/api/todos", todoRoutes); // To-Do 관리
app.use("/api/bookings", bookingRoutes); // 예약 요청 관리
app.use("/api/map-data", mapDataRoutes); // 지도 데이터 조회
app.use("/api/yard-layouts", yardLayoutRoutes); // 야드 관리 3D
app.use("/api/materials", materialRoutes); // 자재 관리
app.use("/api/warehouse", warehouseRoutes); // 창고 관리
// app.use("/api/collections", collectionRoutes); // 임시 주석
// app.use("/api/batch", batchRoutes); // 임시 주석
// app.use('/api/users', userRoutes);
@ -237,7 +235,7 @@ app.listen(PORT, HOST, async () => {
// 대시보드 마이그레이션 실행
try {
const { runDashboardMigration } = await import("./database/runMigration");
const { runDashboardMigration } = await import('./database/runMigration');
await runDashboardMigration();
} catch (error) {
logger.error(`❌ 대시보드 마이그레이션 실패:`, error);

View File

@ -1,68 +0,0 @@
import { Request, Response } from "express";
import MaterialService from "../services/MaterialService";
export class MaterialController {
// 임시 자재 마스터 목록 조회
async getTempMaterials(req: Request, res: Response) {
try {
const { search, category, page, limit } = req.query;
const result = await MaterialService.getTempMaterials({
search: search as string,
category: category as string,
page: page ? parseInt(page as string) : 1,
limit: limit ? parseInt(limit as string) : 20,
});
return res.json({ success: true, ...result });
} catch (error: any) {
console.error("Error fetching temp materials:", error);
return res.status(500).json({
success: false,
message: "자재 목록 조회 중 오류가 발생했습니다.",
error: error.message,
});
}
}
// 특정 자재 상세 조회
async getTempMaterialByCode(req: Request, res: Response) {
try {
const { code } = req.params;
const material = await MaterialService.getTempMaterialByCode(code);
if (!material) {
return res.status(404).json({
success: false,
message: "자재를 찾을 수 없습니다.",
});
}
return res.json({ success: true, data: material });
} catch (error: any) {
console.error("Error fetching temp material:", error);
return res.status(500).json({
success: false,
message: "자재 조회 중 오류가 발생했습니다.",
error: error.message,
});
}
}
// 카테고리 목록 조회
async getCategories(req: Request, res: Response) {
try {
const categories = await MaterialService.getCategories();
return res.json({ success: true, data: categories });
} catch (error: any) {
console.error("Error fetching categories:", error);
return res.status(500).json({
success: false,
message: "카테고리 목록 조회 중 오류가 발생했습니다.",
error: error.message,
});
}
}
}
export default new MaterialController();

View File

@ -0,0 +1,97 @@
import { Request, Response } from "express";
import { WarehouseService } from "../services/WarehouseService";
export class WarehouseController {
private warehouseService: WarehouseService;
constructor() {
this.warehouseService = new WarehouseService();
}
// 창고 및 자재 데이터 조회
getWarehouseData = async (req: Request, res: Response) => {
try {
const data = await this.warehouseService.getWarehouseData();
return res.json({
success: true,
warehouses: data.warehouses,
materials: data.materials,
});
} catch (error: any) {
console.error("창고 데이터 조회 오류:", error);
return res.status(500).json({
success: false,
message: "창고 데이터를 불러오는데 실패했습니다.",
error: error.message,
});
}
};
// 특정 창고 정보 조회
getWarehouseById = async (req: Request, res: Response) => {
try {
const { id } = req.params;
const warehouse = await this.warehouseService.getWarehouseById(id);
if (!warehouse) {
return res.status(404).json({
success: false,
message: "창고를 찾을 수 없습니다.",
});
}
return res.json({
success: true,
data: warehouse,
});
} catch (error: any) {
console.error("창고 조회 오류:", error);
return res.status(500).json({
success: false,
message: "창고 정보를 불러오는데 실패했습니다.",
error: error.message,
});
}
};
// 창고별 자재 목록 조회
getMaterialsByWarehouse = async (req: Request, res: Response) => {
try {
const { warehouseId } = req.params;
const materials =
await this.warehouseService.getMaterialsByWarehouse(warehouseId);
return res.json({
success: true,
data: materials,
});
} catch (error: any) {
console.error("자재 목록 조회 오류:", error);
return res.status(500).json({
success: false,
message: "자재 목록을 불러오는데 실패했습니다.",
error: error.message,
});
}
};
// 창고 통계 조회
getWarehouseStats = async (req: Request, res: Response) => {
try {
const stats = await this.warehouseService.getWarehouseStats();
return res.json({
success: true,
data: stats,
});
} catch (error: any) {
console.error("창고 통계 조회 오류:", error);
return res.status(500).json({
success: false,
message: "창고 통계를 불러오는데 실패했습니다.",
error: error.message,
});
}
};
}

View File

@ -1,299 +0,0 @@
import { Request, Response } from "express";
import YardLayoutService from "../services/YardLayoutService";
export class YardLayoutController {
// 모든 야드 레이아웃 목록 조회
async getAllLayouts(req: Request, res: Response) {
try {
const layouts = await YardLayoutService.getAllLayouts();
res.json({ success: true, data: layouts });
} catch (error: any) {
console.error("Error fetching yard layouts:", error);
res.status(500).json({
success: false,
message: "야드 레이아웃 목록 조회 중 오류가 발생했습니다.",
error: error.message,
});
}
}
// 특정 야드 레이아웃 상세 조회
async getLayoutById(req: Request, res: Response) {
try {
const { id } = req.params;
const layout = await YardLayoutService.getLayoutById(parseInt(id));
if (!layout) {
return res.status(404).json({
success: false,
message: "야드 레이아웃을 찾을 수 없습니다.",
});
}
return res.json({ success: true, data: layout });
} catch (error: any) {
console.error("Error fetching yard layout:", error);
return res.status(500).json({
success: false,
message: "야드 레이아웃 조회 중 오류가 발생했습니다.",
error: error.message,
});
}
}
// 새 야드 레이아웃 생성
async createLayout(req: Request, res: Response) {
try {
const { name, description } = req.body;
if (!name) {
return res.status(400).json({
success: false,
message: "야드 이름은 필수입니다.",
});
}
const created_by = (req as any).user?.userId || "system";
const layout = await YardLayoutService.createLayout({
name,
description,
created_by,
});
return res.status(201).json({ success: true, data: layout });
} catch (error: any) {
console.error("Error creating yard layout:", error);
return res.status(500).json({
success: false,
message: "야드 레이아웃 생성 중 오류가 발생했습니다.",
error: error.message,
});
}
}
// 야드 레이아웃 수정
async updateLayout(req: Request, res: Response) {
try {
const { id } = req.params;
const { name, description } = req.body;
const layout = await YardLayoutService.updateLayout(parseInt(id), {
name,
description,
});
if (!layout) {
return res.status(404).json({
success: false,
message: "야드 레이아웃을 찾을 수 없습니다.",
});
}
return res.json({ success: true, data: layout });
} catch (error: any) {
console.error("Error updating yard layout:", error);
return res.status(500).json({
success: false,
message: "야드 레이아웃 수정 중 오류가 발생했습니다.",
error: error.message,
});
}
}
// 야드 레이아웃 삭제
async deleteLayout(req: Request, res: Response) {
try {
const { id } = req.params;
const layout = await YardLayoutService.deleteLayout(parseInt(id));
if (!layout) {
return res.status(404).json({
success: false,
message: "야드 레이아웃을 찾을 수 없습니다.",
});
}
return res.json({
success: true,
message: "야드 레이아웃이 삭제되었습니다.",
});
} catch (error: any) {
console.error("Error deleting yard layout:", error);
return res.status(500).json({
success: false,
message: "야드 레이아웃 삭제 중 오류가 발생했습니다.",
error: error.message,
});
}
}
// 특정 야드의 모든 배치 자재 조회
async getPlacementsByLayoutId(req: Request, res: Response) {
try {
const { id } = req.params;
const placements = await YardLayoutService.getPlacementsByLayoutId(
parseInt(id)
);
res.json({ success: true, data: placements });
} catch (error: any) {
console.error("Error fetching placements:", error);
res.status(500).json({
success: false,
message: "배치 자재 조회 중 오류가 발생했습니다.",
error: error.message,
});
}
}
// 야드에 자재 배치 추가
async addMaterialPlacement(req: Request, res: Response) {
try {
const { id } = req.params;
const placementData = req.body;
if (!placementData.external_material_id || !placementData.material_code) {
return res.status(400).json({
success: false,
message: "자재 정보가 필요합니다.",
});
}
const placement = await YardLayoutService.addMaterialPlacement(
parseInt(id),
placementData
);
return res.status(201).json({ success: true, data: placement });
} catch (error: any) {
console.error("Error adding material placement:", error);
if (error.code === "23505") {
// 유니크 제약 조건 위반
return res.status(409).json({
success: false,
message: "이미 배치된 자재입니다.",
});
}
return res.status(500).json({
success: false,
message: "자재 배치 추가 중 오류가 발생했습니다.",
error: error.message,
});
}
}
// 배치 정보 수정
async updatePlacement(req: Request, res: Response) {
try {
const { id } = req.params;
const placementData = req.body;
const placement = await YardLayoutService.updatePlacement(
parseInt(id),
placementData
);
if (!placement) {
return res.status(404).json({
success: false,
message: "배치 정보를 찾을 수 없습니다.",
});
}
return res.json({ success: true, data: placement });
} catch (error: any) {
console.error("Error updating placement:", error);
return res.status(500).json({
success: false,
message: "배치 정보 수정 중 오류가 발생했습니다.",
error: error.message,
});
}
}
// 배치 해제
async removePlacement(req: Request, res: Response) {
try {
const { id } = req.params;
const placement = await YardLayoutService.removePlacement(parseInt(id));
if (!placement) {
return res.status(404).json({
success: false,
message: "배치 정보를 찾을 수 없습니다.",
});
}
return res.json({ success: true, message: "배치가 해제되었습니다." });
} catch (error: any) {
console.error("Error removing placement:", error);
return res.status(500).json({
success: false,
message: "배치 해제 중 오류가 발생했습니다.",
error: error.message,
});
}
}
// 여러 배치 일괄 업데이트
async batchUpdatePlacements(req: Request, res: Response) {
try {
const { id } = req.params;
const { placements } = req.body;
if (!Array.isArray(placements) || placements.length === 0) {
return res.status(400).json({
success: false,
message: "배치 목록이 필요합니다.",
});
}
const updatedPlacements = await YardLayoutService.batchUpdatePlacements(
parseInt(id),
placements
);
return res.json({ success: true, data: updatedPlacements });
} catch (error: any) {
console.error("Error batch updating placements:", error);
return res.status(500).json({
success: false,
message: "배치 일괄 업데이트 중 오류가 발생했습니다.",
error: error.message,
});
}
}
// 야드 레이아웃 복제
async duplicateLayout(req: Request, res: Response) {
try {
const { id } = req.params;
const { name } = req.body;
if (!name) {
return res.status(400).json({
success: false,
message: "새 야드 이름은 필수입니다.",
});
}
const layout = await YardLayoutService.duplicateLayout(
parseInt(id),
name
);
return res.status(201).json({ success: true, data: layout });
} catch (error: any) {
console.error("Error duplicating yard layout:", error);
return res.status(500).json({
success: false,
message: "야드 레이아웃 복제 중 오류가 발생했습니다.",
error: error.message,
});
}
}
}
export default new YardLayoutController();

View File

@ -17,54 +17,19 @@ export class OpenApiProxyController {
console.log(`🌤️ 날씨 조회 요청: ${city}`);
// 1순위: OpenWeatherMap API (실시간에 가까움, 10분마다 업데이트)
const openWeatherKey = process.env.OPENWEATHER_API_KEY;
if (openWeatherKey) {
try {
console.log(`🌍 OpenWeatherMap API 호출: ${city}`);
const response = await axios.get('https://api.openweathermap.org/data/2.5/weather', {
params: {
q: `${city},KR`,
appid: openWeatherKey,
units: 'metric',
lang: 'kr',
},
timeout: 10000,
});
const data = response.data;
const weatherData = {
city: data.name,
country: data.sys.country,
temperature: Math.round(data.main.temp),
feelsLike: Math.round(data.main.feels_like),
humidity: data.main.humidity,
pressure: data.main.pressure,
weatherMain: data.weather[0].main,
weatherDescription: data.weather[0].description,
weatherIcon: data.weather[0].icon,
windSpeed: Math.round(data.wind.speed * 10) / 10,
clouds: data.clouds.all,
timestamp: new Date().toISOString(),
};
console.log(`✅ OpenWeatherMap 날씨 조회 성공: ${weatherData.city} ${weatherData.temperature}°C`);
res.json({ success: true, data: weatherData });
return;
} catch (error) {
console.warn('⚠️ OpenWeatherMap API 실패, 기상청 API로 폴백:', error instanceof Error ? error.message : error);
}
}
// 2순위: 기상청 API Hub (매시간 정시 데이터)
// 기상청 API Hub 키 확인
const apiKey = process.env.KMA_API_KEY;
// API 키가 없으면 오류 반환
// API 키가 없으면 테스트 모드로 실시간 날씨 제공
if (!apiKey) {
console.log('⚠️ 기상청 API 키가 설정되지 않았습니다.');
res.status(503).json({
success: false,
message: '기상청 API 키가 설정되지 않았습니다. 관리자에게 문의하세요.',
console.log('⚠️ 기상청 API 키가 없습니다. 테스트 데이터를 반환합니다.');
const regionCode = getKMARegionCode(city as string);
const weatherData = generateRealisticWeatherData(regionCode?.name || (city as string));
res.json({
success: true,
data: weatherData,
});
return;
}
@ -83,39 +48,32 @@ export class OpenApiProxyController {
// 기상청 API Hub 사용 (apihub.kma.go.kr)
const now = new Date();
// 한국 시간(KST = UTC+9)으로 변환
const kstOffset = 9 * 60 * 60 * 1000; // 9시간을 밀리초로
const kstNow = new Date(now.getTime() + kstOffset);
// 기상청 데이터는 매시간 정시(XX:00)에 발표되고 약 10분 후 조회 가능
// 현재 시각이 XX:10 이전이면 이전 시간 데이터 조회
const minute = now.getMinutes();
let targetTime = new Date(now);
// 기상청 지상관측 데이터는 매시간 정시(XX:00)에 발표
// 가장 최근의 정시 데이터를 가져오기 위해 현재 시간의 정시로 설정
const targetTime = new Date(kstNow);
if (minute < 10) {
// 아직 이번 시간 데이터가 업데이트되지 않음 → 이전 시간으로
targetTime = new Date(now.getTime() - 60 * 60 * 1000);
}
// tm 파라미터: YYYYMMDDHH00 형식 (정시만 조회)
const year = targetTime.getUTCFullYear();
const month = String(targetTime.getUTCMonth() + 1).padStart(2, '0');
const day = String(targetTime.getUTCDate()).padStart(2, '0');
const hour = String(targetTime.getUTCHours()).padStart(2, '0');
const year = targetTime.getFullYear();
const month = String(targetTime.getMonth() + 1).padStart(2, '0');
const day = String(targetTime.getDate()).padStart(2, '0');
const hour = String(targetTime.getHours()).padStart(2, '0');
const tm = `${year}${month}${day}${hour}00`;
console.log(`🕐 현재 시각(KST): ${kstNow.toISOString().slice(0, 16).replace('T', ' ')}, 조회 시각: ${tm}`);
// 기상청 API Hub - 지상관측시간자료 (시간 범위 조회로 최신 데이터 확보)
// sfctm3: 시간 범위 조회 가능 (tm1~tm2)
const url = 'https://apihub.kma.go.kr/api/typ01/url/kma_sfctm3.php';
// 최근 1시간 범위 조회 (현재 시간 - 1시간 ~ 현재 시간) - KST 기준
const tm1Time = new Date(kstNow.getTime() - 60 * 60 * 1000); // 1시간 전
const tm1 = `${tm1Time.getUTCFullYear()}${String(tm1Time.getUTCMonth() + 1).padStart(2, '0')}${String(tm1Time.getUTCDate()).padStart(2, '0')}${String(tm1Time.getUTCHours()).padStart(2, '0')}00`;
const tm2 = tm; // 현재 시간
// 기상청 API Hub - 지상관측시간자료
const url = 'https://apihub.kma.go.kr/api/typ01/url/kma_sfctm2.php';
console.log(`📡 기상청 API Hub 호출: ${regionCode.name} (관측소: ${regionCode.stnId}, 기간: ${tm1}~${tm2})`);
console.log(`📡 기상청 API Hub 호출: ${regionCode.name} (관측소: ${regionCode.stnId}, 시간: ${tm})`);
const response = await axios.get(url, {
params: {
tm1: tm1,
tm2: tm2,
stn: regionCode.stnId, // 특정 관측소만 조회
tm: tm,
stn: 0, // 0 = 전체 관측소 데이터 조회
authKey: apiKey,
help: 0,
disp: 1,
@ -137,36 +95,30 @@ export class OpenApiProxyController {
} catch (error: unknown) {
console.error('❌ 날씨 조회 실패:', error);
// API 호출 실패 시 명확한 오류 메시지 반
// API 호출 실패 시 자동으로 테스트 모드로 전
if (axios.isAxiosError(error)) {
const status = error.response?.status;
if (status === 401 || status === 403) {
res.status(401).json({
success: false,
message: '기상청 API 인증에 실패했습니다. API 키를 확인하세요.',
});
} else if (status === 404) {
res.status(404).json({
success: false,
message: '기상청 API에서 데이터를 찾을 수 없습니다.',
});
} else if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') {
res.status(504).json({
success: false,
message: '기상청 API 연결 시간이 초과되었습니다. 잠시 후 다시 시도하세요.',
});
} else {
res.status(500).json({
success: false,
message: '기상청 API 호출 중 오류가 발생했습니다.',
error: error.message,
});
}
// 모든 오류 → 테스트 데이터 반환
console.log('⚠️ API 오류 발생. 테스트 데이터를 반환합니다.');
const { city = '서울' } = req.query;
const regionCode = getKMARegionCode(city as string);
const weatherData = generateRealisticWeatherData(regionCode?.name || (city as string));
res.json({
success: true,
data: weatherData,
});
} else {
res.status(500).json({
success: false,
message: '날씨 정보를 가져오는 중 예상치 못한 오류가 발생했습니다.',
// 예상치 못한 오류 → 테스트 데이터 반환
console.log('⚠️ 예상치 못한 오류. 테스트 데이터를 반환합니다.');
const { city = '서울' } = req.query;
const regionCode = getKMARegionCode(city as string);
const weatherData = generateRealisticWeatherData(regionCode?.name || (city as string));
res.json({
success: true,
data: weatherData,
});
}
}
@ -217,19 +169,15 @@ export class OpenApiProxyController {
} catch (error: unknown) {
console.error('❌ 환율 조회 실패:', error);
// API 호출 실패 시 명확한 오류 메시지 반환
if (axios.isAxiosError(error)) {
res.status(500).json({
success: false,
message: '환율 정보를 가져오는 중 오류가 발생했습니다.',
error: error.message,
});
} else {
res.status(500).json({
success: false,
message: '환율 정보를 가져오는 중 예상치 못한 오류가 발생했습니다.',
});
}
// API 호출 실패 시 실제 근사값 반환
console.log('⚠️ API 오류 발생. 근사값을 반환합니다.');
const { base = 'KRW', target = 'USD' } = req.query;
const approximateRate = generateRealisticExchangeRate(base as string, target as string);
res.json({
success: true,
data: approximateRate,
});
}
}
@ -657,26 +605,19 @@ function parseKMAHubWeatherData(data: any, regionCode: { name: string; stnId: st
throw new Error('날씨 데이터를 파싱할 수 없습니다.');
}
// 요청한 관측소(stnId)의 모든 데이터 찾기 (시간 범위 조회 시 여러 줄 반환됨)
const targetLines = lines.filter((line: string) => {
// 요청한 관측소(stnId)의 데이터 찾기
const targetLine = lines.find((line: string) => {
const cols = line.trim().split(/\s+/);
return cols[1] === regionCode.stnId; // STN 컬럼 (인덱스 1)
});
if (targetLines.length === 0) {
if (!targetLine) {
throw new Error(`${regionCode.name} 관측소 데이터를 찾을 수 없습니다.`);
}
// 가장 최근 데이터 선택 (마지막 줄)
const targetLine = targetLines[targetLines.length - 1];
// 데이터 라인 파싱 (공백으로 구분)
const values = targetLine.trim().split(/\s+/);
// 관측 시각 로깅
const obsTime = values[0]; // YYMMDDHHMI
console.log(`🕐 관측 시각: ${obsTime} (${regionCode.name})`);
// 기상청 API Hub 데이터 형식 (실제 응답 기준):
// [0]YYMMDDHHMI [1]STN [2]WD [3]WS [4]GST_WD [5]GST_WS [6]GST_TM [7]PA [8]PS [9]PT [10]PR [11]TA [12]TD [13]HM [14]PV [15]RN ...
const temperature = parseFloat(values[11]) || 0; // TA: 기온 (인덱스 11)

View File

@ -1,15 +0,0 @@
import express from "express";
import MaterialController from "../controllers/MaterialController";
import { authenticateToken } from "../middleware/authMiddleware";
const router = express.Router();
// 모든 라우트에 인증 미들웨어 적용
router.use(authenticateToken);
// 임시 자재 마스터 관리
router.get("/temp", MaterialController.getTempMaterials);
router.get("/temp/categories", MaterialController.getCategories);
router.get("/temp/:code", MaterialController.getTempMaterialByCode);
export default router;

View File

@ -1,52 +0,0 @@
import express from "express";
import { query } from "../database/db";
const router = express.Router();
/**
* API
* - active/warning
*/
router.post("/move", async (req, res) => {
try {
// move_vehicles() 함수 실행
await query("SELECT move_vehicles()");
res.json({
success: true,
message: "차량 위치가 업데이트되었습니다"
});
} catch (error) {
console.error("차량 위치 업데이트 오류:", error);
res.status(500).json({
success: false,
error: "차량 위치 업데이트 실패"
});
}
});
/**
*
*/
router.get("/locations", async (req, res) => {
try {
const result = await query(`
SELECT * FROM vehicle_locations
ORDER BY last_update DESC
`);
res.json({
success: true,
data: result
});
} catch (error) {
console.error("차량 위치 조회 오류:", error);
res.status(500).json({
success: false,
error: "차량 위치 조회 실패"
});
}
});
export default router;

View File

@ -0,0 +1,22 @@
import { Router } from "express";
import { WarehouseController } from "../controllers/WarehouseController";
const router = Router();
const warehouseController = new WarehouseController();
// 창고 및 자재 데이터 조회
router.get("/data", warehouseController.getWarehouseData);
// 특정 창고 정보 조회
router.get("/:id", warehouseController.getWarehouseById);
// 창고별 자재 목록 조회
router.get(
"/:warehouseId/materials",
warehouseController.getMaterialsByWarehouse
);
// 창고 통계 조회
router.get("/stats/summary", warehouseController.getWarehouseStats);
export default router;

View File

@ -1,27 +0,0 @@
import express from "express";
import YardLayoutController from "../controllers/YardLayoutController";
import { authenticateToken } from "../middleware/authMiddleware";
const router = express.Router();
// 모든 라우트에 인증 미들웨어 적용
router.use(authenticateToken);
// 야드 레이아웃 관리
router.get("/", YardLayoutController.getAllLayouts);
router.get("/:id", YardLayoutController.getLayoutById);
router.post("/", YardLayoutController.createLayout);
router.put("/:id", YardLayoutController.updateLayout);
router.delete("/:id", YardLayoutController.deleteLayout);
router.post("/:id/duplicate", YardLayoutController.duplicateLayout);
// 자재 배치 관리
router.get("/:id/placements", YardLayoutController.getPlacementsByLayoutId);
router.post("/:id/placements", YardLayoutController.addMaterialPlacement);
router.put("/:id/placements/batch", YardLayoutController.batchUpdatePlacements);
// 개별 배치 관리 (별도 경로)
router.put("/placements/:id", YardLayoutController.updatePlacement);
router.delete("/placements/:id", YardLayoutController.removePlacement);
export default router;

View File

@ -61,9 +61,8 @@ export class DashboardService {
id, dashboard_id, element_type, element_subtype,
position_x, position_y, width, height,
title, custom_title, show_header, content, data_source_config, chart_config,
list_config, yard_config,
display_order, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
`,
[
elementId,
@ -80,8 +79,6 @@ export class DashboardService {
element.content || null,
JSON.stringify(element.dataSource || {}),
JSON.stringify(element.chartConfig || {}),
JSON.stringify(element.listConfig || null),
JSON.stringify(element.yardConfig || null),
i,
now,
now,
@ -345,16 +342,6 @@ export class DashboardService {
content: row.content,
dataSource: JSON.parse(row.data_source_config || "{}"),
chartConfig: JSON.parse(row.chart_config || "{}"),
listConfig: row.list_config
? typeof row.list_config === "string"
? JSON.parse(row.list_config)
: row.list_config
: undefined,
yardConfig: row.yard_config
? typeof row.yard_config === "string"
? JSON.parse(row.yard_config)
: row.yard_config
: undefined,
})
);
@ -478,9 +465,8 @@ export class DashboardService {
id, dashboard_id, element_type, element_subtype,
position_x, position_y, width, height,
title, custom_title, show_header, content, data_source_config, chart_config,
list_config, yard_config,
display_order, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
`,
[
elementId,
@ -497,8 +483,6 @@ export class DashboardService {
element.content || null,
JSON.stringify(element.dataSource || {}),
JSON.stringify(element.chartConfig || {}),
JSON.stringify(element.listConfig || null),
JSON.stringify(element.yardConfig || null),
i,
now,
now,

View File

@ -1,111 +0,0 @@
import { getPool } from "../database/db";
export class MaterialService {
// 임시 자재 마스터 목록 조회
async getTempMaterials(params: {
search?: string;
category?: string;
page?: number;
limit?: number;
}) {
const { search, category, page = 1, limit = 20 } = params;
const offset = (page - 1) * limit;
let whereConditions: string[] = ["is_active = true"];
const queryParams: any[] = [];
let paramIndex = 1;
if (search) {
whereConditions.push(
`(material_code ILIKE $${paramIndex} OR material_name ILIKE $${paramIndex})`
);
queryParams.push(`%${search}%`);
paramIndex++;
}
if (category) {
whereConditions.push(`category = $${paramIndex}`);
queryParams.push(category);
paramIndex++;
}
const whereClause =
whereConditions.length > 0
? `WHERE ${whereConditions.join(" AND ")}`
: "";
const pool = getPool();
// 전체 개수 조회
const countQuery = `SELECT COUNT(*) as total FROM temp_material_master ${whereClause}`;
const countResult = await pool.query(countQuery, queryParams);
const total = parseInt(countResult.rows[0].total);
// 데이터 조회
const dataQuery = `
SELECT
id,
material_code,
material_name,
category,
unit,
default_color,
description,
created_at
FROM temp_material_master
${whereClause}
ORDER BY material_code ASC
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
`;
queryParams.push(limit, offset);
const dataResult = await pool.query(dataQuery, queryParams);
return {
data: dataResult.rows,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
};
}
// 특정 자재 상세 조회
async getTempMaterialByCode(materialCode: string) {
const query = `
SELECT
id,
material_code,
material_name,
category,
unit,
default_color,
description,
created_at
FROM temp_material_master
WHERE material_code = $1 AND is_active = true
`;
const pool = getPool();
const result = await pool.query(query, [materialCode]);
return result.rows[0] || null;
}
// 카테고리 목록 조회
async getCategories() {
const query = `
SELECT DISTINCT category
FROM temp_material_master
WHERE is_active = true AND category IS NOT NULL
ORDER BY category ASC
`;
const pool = getPool();
const result = await pool.query(query);
return result.rows.map((row) => row.category);
}
}
export default new MaterialService();

View File

@ -0,0 +1,170 @@
import pool from "../database/db";
export class WarehouseService {
// 창고 및 자재 데이터 조회
async getWarehouseData() {
try {
// 창고 목록 조회
const warehousesResult = await pool.query(`
SELECT
id,
name,
position_x,
position_y,
position_z,
size_x,
size_y,
size_z,
color,
capacity,
current_usage,
status,
description,
created_at,
updated_at
FROM warehouse
WHERE status = 'active'
ORDER BY id
`);
// 자재 목록 조회
const materialsResult = await pool.query(`
SELECT
id,
warehouse_id,
name,
material_code,
quantity,
unit,
position_x,
position_y,
position_z,
size_x,
size_y,
size_z,
color,
status,
last_updated,
created_at
FROM warehouse_material
ORDER BY warehouse_id, id
`);
return {
warehouses: warehousesResult,
materials: materialsResult,
};
} catch (error) {
throw error;
}
}
// 특정 창고 정보 조회
async getWarehouseById(id: string) {
try {
const result = await pool.query(
`
SELECT
id,
name,
position_x,
position_y,
position_z,
size_x,
size_y,
size_z,
color,
capacity,
current_usage,
status,
description,
created_at,
updated_at
FROM warehouse
WHERE id = $1
`,
[id]
);
return result[0] || null;
} catch (error) {
throw error;
}
}
// 창고별 자재 목록 조회
async getMaterialsByWarehouse(warehouseId: string) {
try {
const result = await pool.query(
`
SELECT
id,
warehouse_id,
name,
material_code,
quantity,
unit,
position_x,
position_y,
position_z,
size_x,
size_y,
size_z,
color,
status,
last_updated,
created_at
FROM warehouse_material
WHERE warehouse_id = $1
ORDER BY id
`,
[warehouseId]
);
return result;
} catch (error) {
throw error;
}
}
// 창고 통계 조회
async getWarehouseStats() {
try {
const result = await pool.query(`
SELECT
COUNT(DISTINCT w.id) as total_warehouses,
COUNT(m.id) as total_materials,
SUM(w.capacity) as total_capacity,
SUM(w.current_usage) as total_usage,
ROUND(AVG((w.current_usage::numeric / NULLIF(w.capacity, 0)) * 100), 2) as avg_usage_percent
FROM warehouse w
LEFT JOIN warehouse_material m ON w.id = m.warehouse_id
WHERE w.status = 'active'
`);
// 상태별 자재 수
const statusResult = await pool.query(`
SELECT
status,
COUNT(*) as count
FROM warehouse_material
GROUP BY status
`);
const statusCounts = statusResult.reduce(
(acc: Record<string, number>, row: any) => {
acc[row.status] = parseInt(row.count);
return acc;
},
{} as Record<string, number>
);
return {
...result[0],
materialsByStatus: statusCounts,
};
} catch (error) {
throw error;
}
}
}

View File

@ -1,337 +0,0 @@
import { getPool } from "../database/db";
export class YardLayoutService {
// 모든 야드 레이아웃 목록 조회
async getAllLayouts() {
const query = `
SELECT
yl.id,
yl.name,
yl.description,
yl.created_by,
yl.created_at,
yl.updated_at,
COUNT(ymp.id) as placement_count
FROM yard_layout yl
LEFT JOIN yard_material_placement ymp ON yl.id = ymp.yard_layout_id
GROUP BY yl.id
ORDER BY yl.updated_at DESC
`;
const pool = getPool();
const result = await pool.query(query);
return result.rows;
}
// 특정 야드 레이아웃 상세 조회
async getLayoutById(id: number) {
const query = `
SELECT
id,
name,
description,
created_by,
created_at,
updated_at
FROM yard_layout
WHERE id = $1
`;
const pool = getPool();
const result = await pool.query(query, [id]);
return result.rows[0] || null;
}
// 새 야드 레이아웃 생성
async createLayout(data: {
name: string;
description?: string;
created_by?: string;
}) {
const query = `
INSERT INTO yard_layout (name, description, created_by)
VALUES ($1, $2, $3)
RETURNING *
`;
const pool = getPool();
const result = await pool.query(query, [
data.name,
data.description || null,
data.created_by || null,
]);
return result.rows[0];
}
// 야드 레이아웃 수정 (이름, 설명만)
async updateLayout(
id: number,
data: { name?: string; description?: string }
) {
const query = `
UPDATE yard_layout
SET
name = COALESCE($1, name),
description = COALESCE($2, description),
updated_at = CURRENT_TIMESTAMP
WHERE id = $3
RETURNING *
`;
const pool = getPool();
const result = await pool.query(query, [
data.name || null,
data.description || null,
id,
]);
return result.rows[0] || null;
}
// 야드 레이아웃 삭제
async deleteLayout(id: number) {
const query = `DELETE FROM yard_layout WHERE id = $1 RETURNING *`;
const pool = getPool();
const result = await pool.query(query, [id]);
return result.rows[0] || null;
}
// 특정 야드의 모든 배치 자재 조회
async getPlacementsByLayoutId(layoutId: number) {
const query = `
SELECT
id,
yard_layout_id,
external_material_id,
material_code,
material_name,
quantity,
unit,
position_x,
position_y,
position_z,
size_x,
size_y,
size_z,
color,
memo,
created_at,
updated_at
FROM yard_material_placement
WHERE yard_layout_id = $1
ORDER BY created_at ASC
`;
const pool = getPool();
const result = await pool.query(query, [layoutId]);
return result.rows;
}
// 야드에 자재 배치 추가
async addMaterialPlacement(layoutId: number, data: any) {
const query = `
INSERT INTO yard_material_placement (
yard_layout_id,
external_material_id,
material_code,
material_name,
quantity,
unit,
position_x,
position_y,
position_z,
size_x,
size_y,
size_z,
color,
memo
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
RETURNING *
`;
const pool = getPool();
const result = await pool.query(query, [
layoutId,
data.external_material_id,
data.material_code,
data.material_name,
data.quantity,
data.unit,
data.position_x || 0,
data.position_y || 0,
data.position_z || 0,
data.size_x || 5,
data.size_y || 5,
data.size_z || 5,
data.color || "#3b82f6",
data.memo || null,
]);
return result.rows[0];
}
// 배치 정보 수정 (위치, 크기, 색상, 메모만)
async updatePlacement(placementId: number, data: any) {
const query = `
UPDATE yard_material_placement
SET
position_x = COALESCE($1, position_x),
position_y = COALESCE($2, position_y),
position_z = COALESCE($3, position_z),
size_x = COALESCE($4, size_x),
size_y = COALESCE($5, size_y),
size_z = COALESCE($6, size_z),
color = COALESCE($7, color),
memo = COALESCE($8, memo),
updated_at = CURRENT_TIMESTAMP
WHERE id = $9
RETURNING *
`;
const pool = getPool();
const result = await pool.query(query, [
data.position_x,
data.position_y,
data.position_z,
data.size_x,
data.size_y,
data.size_z,
data.color,
data.memo,
placementId,
]);
return result.rows[0] || null;
}
// 배치 해제 (자재는 삭제되지 않음)
async removePlacement(placementId: number) {
const query = `DELETE FROM yard_material_placement WHERE id = $1 RETURNING *`;
const pool = getPool();
const result = await pool.query(query, [placementId]);
return result.rows[0] || null;
}
// 여러 배치 일괄 업데이트
async batchUpdatePlacements(layoutId: number, placements: any[]) {
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
const results = [];
for (const placement of placements) {
const query = `
UPDATE yard_material_placement
SET
position_x = $1,
position_y = $2,
position_z = $3,
size_x = $4,
size_y = $5,
size_z = $6,
updated_at = CURRENT_TIMESTAMP
WHERE id = $7 AND yard_layout_id = $8
RETURNING *
`;
const result = await client.query(query, [
placement.position_x,
placement.position_y,
placement.position_z,
placement.size_x,
placement.size_y,
placement.size_z,
placement.id,
layoutId,
]);
if (result.rows[0]) {
results.push(result.rows[0]);
}
}
await client.query("COMMIT");
return results;
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
}
// 야드 레이아웃 복제
async duplicateLayout(id: number, newName: string) {
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
// 원본 레이아웃 조회
const layoutQuery = `SELECT * FROM yard_layout WHERE id = $1`;
const layoutResult = await client.query(layoutQuery, [id]);
const originalLayout = layoutResult.rows[0];
if (!originalLayout) {
throw new Error("Layout not found");
}
// 새 레이아웃 생성
const newLayoutQuery = `
INSERT INTO yard_layout (name, description, created_by)
VALUES ($1, $2, $3)
RETURNING *
`;
const newLayoutResult = await client.query(newLayoutQuery, [
newName,
originalLayout.description,
originalLayout.created_by,
]);
const newLayout = newLayoutResult.rows[0];
// 배치 자재 복사
const placementsQuery = `SELECT * FROM yard_material_placement WHERE yard_layout_id = $1`;
const placementsResult = await client.query(placementsQuery, [id]);
for (const placement of placementsResult.rows) {
await client.query(
`
INSERT INTO yard_material_placement (
yard_layout_id, external_material_id, material_code, material_name,
quantity, unit, position_x, position_y, position_z,
size_x, size_y, size_z, color, memo
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
`,
[
newLayout.id,
placement.external_material_id,
placement.material_code,
placement.material_name,
placement.quantity,
placement.unit,
placement.position_x,
placement.position_y,
placement.position_z,
placement.size_x,
placement.size_y,
placement.size_z,
placement.color,
placement.memo,
]
);
}
await client.query("COMMIT");
return newLayout;
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
}
}
export default new YardLayoutService();

View File

@ -25,8 +25,8 @@ export class RiskAlertService {
const apiKey = process.env.KMA_API_KEY;
if (!apiKey) {
console.log('⚠️ 기상청 API 키가 없습니다. 데이터를 반환합니다.');
return [];
console.log('⚠️ 기상청 API 키가 없습니다. 테스트 데이터를 반환합니다.');
return this.generateDummyWeatherAlerts();
}
const alerts: Alert[] = [];
@ -109,7 +109,7 @@ export class RiskAlertService {
console.log(`✅ 총 ${alerts.length}건의 기상특보 감지`);
} catch (warningError: any) {
console.error('❌ 기상청 특보 API 오류:', warningError.message);
return [];
return this.generateDummyWeatherAlerts();
}
// 특보가 없으면 빈 배열 반환 (0건)
@ -120,8 +120,8 @@ export class RiskAlertService {
return alerts;
} catch (error: any) {
console.error('❌ 기상청 특보 API 오류:', error.message);
// API 오류 시 빈 배열 반환
return [];
// API 오류 시 더미 데이터 반환
return this.generateDummyWeatherAlerts();
}
}
@ -237,9 +237,9 @@ export class RiskAlertService {
console.error('❌ 한국도로공사 API 오류:', error.message);
}
// 모든 API 실패 시 빈 배열
console.log(' 모든 교통사고 API 실패. 빈 배열을 반환합니다.');
return [];
// 모든 API 실패 시 더미 데이터
console.log(' 모든 교통사고 API 실패. 더미 데이터를 반환합니다.');
return this.generateDummyAccidentAlerts();
}
/**
@ -356,9 +356,9 @@ export class RiskAlertService {
console.error('❌ 한국도로공사 API 오류:', error.message);
}
// 모든 API 실패 시 빈 배열
console.log(' 모든 도로공사 API 실패. 빈 배열을 반환합니다.');
return [];
// 모든 API 실패 시 더미 데이터
console.log(' 모든 도로공사 API 실패. 더미 데이터를 반환합니다.');
return this.generateDummyRoadworkAlerts();
}
/**
@ -467,5 +467,82 @@ export class RiskAlertService {
return 'low';
}
/**
*
*/
private generateDummyWeatherAlerts(): Alert[] {
return [
{
id: `weather-${Date.now()}-1`,
type: 'weather',
severity: 'high',
title: '대설특보',
location: '강원 영동지역',
description: '시간당 2cm 이상 폭설. 차량 운행 주의',
timestamp: new Date(Date.now() - 30 * 60000).toISOString(),
},
{
id: `weather-${Date.now()}-2`,
type: 'weather',
severity: 'medium',
title: '강풍특보',
location: '남해안 전 지역',
description: '순간 풍속 20m/s 이상. 고속도로 주행 주의',
timestamp: new Date(Date.now() - 90 * 60000).toISOString(),
},
];
}
/**
*
*/
private generateDummyAccidentAlerts(): Alert[] {
return [
{
id: `accident-${Date.now()}-1`,
type: 'accident',
severity: 'high',
title: '교통사고 발생',
location: '경부고속도로 서울방향 189km',
description: '3중 추돌사고로 2차로 통제 중. 우회 권장',
timestamp: new Date(Date.now() - 10 * 60000).toISOString(),
},
{
id: `accident-${Date.now()}-2`,
type: 'accident',
severity: 'medium',
title: '사고 다발 지역',
location: '영동고속도로 강릉방향 160km',
description: '안개로 인한 가시거리 50m 이하. 서행 운전',
timestamp: new Date(Date.now() - 60 * 60000).toISOString(),
},
];
}
/**
*
*/
private generateDummyRoadworkAlerts(): Alert[] {
return [
{
id: `construction-${Date.now()}-1`,
type: 'construction',
severity: 'medium',
title: '도로 공사',
location: '서울외곽순환 목동IC~화곡IC',
description: '야간 공사로 1차로 통제 (22:00~06:00)',
timestamp: new Date(Date.now() - 45 * 60000).toISOString(),
},
{
id: `construction-${Date.now()}-2`,
type: 'construction',
severity: 'low',
title: '도로 통제',
location: '중부내륙고속도로 김천JC~현풍IC',
description: '도로 유지보수 작업. 차량 속도 제한 60km/h',
timestamp: new Date(Date.now() - 120 * 60000).toISOString(),
},
];
}
}

View File

@ -35,16 +35,6 @@ export interface DashboardElement {
title?: string;
showLegend?: boolean;
};
listConfig?: {
columns?: any[];
pagination?: any;
viewMode?: string;
cardColumns?: number;
};
yardConfig?: {
layoutId: number;
layoutName?: string;
};
}
export interface Dashboard {

View File

@ -25,12 +25,12 @@ export default function DashboardViewPage({ params }: DashboardViewPageProps) {
title: string;
description?: string;
elements: DashboardElement[];
settings?: {
backgroundColor?: string;
resolution?: string;
};
createdAt: string;
updatedAt: string;
settings?: {
resolution?: string;
backgroundColor?: string;
};
} | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@ -105,65 +105,8 @@ export default function DashboardViewPage({ params }: DashboardViewPageProps) {
return (
<div className="h-screen bg-gray-50">
{/* 대시보드 헤더 - 보기 모드에서는 숨김 */}
{/* <div className="border-b border-gray-200 bg-white px-6 py-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-800">{dashboard.title}</h1>
{dashboard.description && <p className="mt-1 text-sm text-gray-600">{dashboard.description}</p>}
</div>
<div className="flex items-center gap-3">
{/* *\/}
<button
onClick={loadDashboard}
className="rounded-lg border border-gray-300 px-3 py-2 text-gray-600 hover:bg-gray-50 hover:text-gray-800"
title="새로고침"
>
🔄
</button>
{/* *\/}
<button
onClick={() => {
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
document.documentElement.requestFullscreen();
}
}}
className="rounded-lg border border-gray-300 px-3 py-2 text-gray-600 hover:bg-gray-50 hover:text-gray-800"
title="전체화면"
>
</button>
{/* *\/}
<button
onClick={() => {
router.push(`/admin/dashboard?load=${resolvedParams.dashboardId}`);
}}
className="rounded-lg bg-blue-500 px-4 py-2 text-white hover:bg-blue-600"
>
</button>
</div>
</div>
{/* *\/}
<div className="mt-2 flex items-center gap-4 text-xs text-gray-500">
<span>: {new Date(dashboard.createdAt).toLocaleString()}</span>
<span>: {new Date(dashboard.updatedAt).toLocaleString()}</span>
<span>: {dashboard.elements.length}</span>
</div>
</div> */}
{/* 대시보드 뷰어 */}
<DashboardViewer
elements={dashboard.elements}
dashboardId={dashboard.id}
backgroundColor={dashboard.settings?.backgroundColor}
/>
{/* 대시보드 뷰어 - 전체 화면 */}
<DashboardViewer elements={dashboard.elements} resolution={dashboard.settings?.resolution || "fhd"} />
</div>
);
}

View File

@ -106,12 +106,6 @@ import { CalendarWidget } from "./widgets/CalendarWidget";
import { DriverManagementWidget } from "./widgets/DriverManagementWidget";
import { ListWidget } from "./widgets/ListWidget";
// 야드 관리 3D 위젯
const YardManagement3DWidget = dynamic(() => import("./widgets/YardManagement3DWidget"), {
ssr: false,
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500"> ...</div>,
});
interface CanvasElementProps {
element: DashboardElement;
isSelected: boolean;
@ -214,7 +208,7 @@ export function CanvasElement({
const deltaX = e.clientX - dragStart.x;
const deltaY = e.clientY - dragStart.y;
// 임시 위치 계산
// 임시 위치 계산 (스냅 안 됨)
let rawX = Math.max(0, dragStart.elementX + deltaX);
const rawY = Math.max(0, dragStart.elementY + deltaY);
@ -222,22 +216,7 @@ export function CanvasElement({
const maxX = canvasWidth - element.size.width;
rawX = Math.min(rawX, maxX);
// 드래그 중 실시간 스냅 (마그네틱 스냅)
const subGridSize = Math.floor(cellSize / 3);
const gridSize = cellSize + 5; // GAP 포함한 실제 그리드 크기
const magneticThreshold = 15; // 큰 그리드에 끌리는 거리 (px)
// X 좌표 스냅 (큰 그리드 우선, 없으면 서브그리드)
const nearestGridX = Math.round(rawX / gridSize) * gridSize;
const distToGridX = Math.abs(rawX - nearestGridX);
const snappedX = distToGridX <= magneticThreshold ? nearestGridX : Math.round(rawX / subGridSize) * subGridSize;
// Y 좌표 스냅 (큰 그리드 우선, 없으면 서브그리드)
const nearestGridY = Math.round(rawY / gridSize) * gridSize;
const distToGridY = Math.abs(rawY - nearestGridY);
const snappedY = distToGridY <= magneticThreshold ? nearestGridY : Math.round(rawY / subGridSize) * subGridSize;
setTempPosition({ x: snappedX, y: snappedY });
setTempPosition({ x: rawX, y: rawY });
} else if (isResizing) {
const deltaX = e.clientX - resizeStart.x;
const deltaY = e.clientY - resizeStart.y;
@ -280,89 +259,46 @@ export function CanvasElement({
const maxWidth = canvasWidth - newX;
newWidth = Math.min(newWidth, maxWidth);
// 리사이즈 중 실시간 스냅 (마그네틱 스냅)
const subGridSize = Math.floor(cellSize / 3);
const gridSize = cellSize + 5; // GAP 포함한 실제 그리드 크기
const magneticThreshold = 15;
// 위치 스냅
const nearestGridX = Math.round(newX / gridSize) * gridSize;
const distToGridX = Math.abs(newX - nearestGridX);
const snappedX = distToGridX <= magneticThreshold ? nearestGridX : Math.round(newX / subGridSize) * subGridSize;
const nearestGridY = Math.round(newY / gridSize) * gridSize;
const distToGridY = Math.abs(newY - nearestGridY);
const snappedY = distToGridY <= magneticThreshold ? nearestGridY : Math.round(newY / subGridSize) * subGridSize;
// 크기 스냅 (그리드 칸 단위로 스냅하되, 마지막 GAP은 제외)
// 예: 1칸 = cellSize, 2칸 = cellSize*2 + GAP, 3칸 = cellSize*3 + GAP*2
const calculateGridWidth = (cells: number) => cells * cellSize + Math.max(0, cells - 1) * 5;
// 가장 가까운 그리드 칸 수 계산
const nearestWidthCells = Math.round(newWidth / gridSize);
const nearestGridWidth = calculateGridWidth(nearestWidthCells);
const distToGridWidth = Math.abs(newWidth - nearestGridWidth);
const snappedWidth =
distToGridWidth <= magneticThreshold ? nearestGridWidth : Math.round(newWidth / subGridSize) * subGridSize;
const nearestHeightCells = Math.round(newHeight / gridSize);
const nearestGridHeight = calculateGridWidth(nearestHeightCells);
const distToGridHeight = Math.abs(newHeight - nearestGridHeight);
const snappedHeight =
distToGridHeight <= magneticThreshold ? nearestGridHeight : Math.round(newHeight / subGridSize) * subGridSize;
// 임시 크기/위치 저장 (스냅됨)
setTempPosition({ x: Math.max(0, snappedX), y: Math.max(0, snappedY) });
setTempSize({ width: snappedWidth, height: snappedHeight });
// 임시 크기/위치 저장 (스냅 안 됨)
setTempPosition({ x: Math.max(0, newX), y: Math.max(0, newY) });
setTempSize({ width: newWidth, height: newHeight });
}
},
[
isDragging,
isResizing,
dragStart,
resizeStart,
element.size.width,
element.type,
element.subtype,
canvasWidth,
cellSize,
],
[isDragging, isResizing, dragStart, resizeStart, element.size.width, element.type, element.subtype, canvasWidth],
);
// 마우스 업 처리 (이미 스냅된 위치 사용)
// 마우스 업 처리 (그리드 스냅 적용)
const handleMouseUp = useCallback(() => {
if (isDragging && tempPosition) {
// tempPosition은 이미 드래그 중에 마그네틱 스냅 적용됨
// 다시 스냅하지 않고 그대로 사용!
let finalX = tempPosition.x;
const finalY = tempPosition.y;
// 드래그 종료 시 그리드에 스냅 (동적 셀 크기 사용)
let snappedX = snapToGrid(tempPosition.x, cellSize);
const snappedY = snapToGrid(tempPosition.y, cellSize);
// X 좌표가 캔버스 너비를 벗어나지 않도록 최종 제한
const maxX = canvasWidth - element.size.width;
finalX = Math.min(finalX, maxX);
snappedX = Math.min(snappedX, maxX);
onUpdate(element.id, {
position: { x: finalX, y: finalY },
position: { x: snappedX, y: snappedY },
});
setTempPosition(null);
}
if (isResizing && tempPosition && tempSize) {
// tempPosition과 tempSize는 이미 리사이즈 중에 마그네틱 스냅 적용됨
// 다시 스냅하지 않고 그대로 사용!
const finalX = tempPosition.x;
const finalY = tempPosition.y;
let finalWidth = tempSize.width;
const finalHeight = tempSize.height;
// 리사이즈 종료 시 그리드에 스냅 (동적 셀 크기 사용)
const snappedX = snapToGrid(tempPosition.x, cellSize);
const snappedY = snapToGrid(tempPosition.y, cellSize);
let snappedWidth = snapSizeToGrid(tempSize.width, 2, cellSize);
const snappedHeight = snapSizeToGrid(tempSize.height, 2, cellSize);
// 가로 너비가 캔버스를 벗어나지 않도록 최종 제한
const maxWidth = canvasWidth - finalX;
finalWidth = Math.min(finalWidth, maxWidth);
const maxWidth = canvasWidth - snappedX;
snappedWidth = Math.min(snappedWidth, maxWidth);
onUpdate(element.id, {
position: { x: finalX, y: finalY },
size: { width: finalWidth, height: finalHeight },
position: { x: snappedX, y: snappedY },
size: { width: snappedWidth, height: snappedHeight },
});
setTempPosition(null);
@ -432,7 +368,7 @@ export function CanvasElement({
});
}
} catch (error) {
// console.error("Chart data loading error:", error);
console.error("Chart data loading error:", error);
setChartData(null);
} finally {
setIsLoadingData(false);
@ -521,9 +457,12 @@ export function CanvasElement({
<div className="flex cursor-move items-center justify-between border-b border-gray-200 bg-gray-50 p-3">
<span className="text-sm font-bold text-gray-800">{element.customTitle || element.title}</span>
<div className="flex gap-1">
{/* 설정 버튼 (기사관리 위젯만 자체 설정 UI 사용) */}
{/* 설정 버튼 (시계, 달력, 기사관리 위젯은 자체 설정 UI 사용) */}
{onConfigure &&
!(element.type === "widget" && element.subtype === "driver-management") && (
!(
element.type === "widget" &&
(element.subtype === "clock" || element.subtype === "calendar" || element.subtype === "driver-management")
) && (
<button
className="hover:bg-accent0 flex h-6 w-6 items-center justify-center rounded text-gray-400 transition-colors duration-200 hover:text-white"
onClick={() => onConfigure(element)}
@ -710,21 +649,10 @@ export function CanvasElement({
}}
/>
</div>
) : element.type === "widget" && element.subtype === "yard-management-3d" ? (
// 야드 관리 3D 위젯 렌더링
<div className="widget-interactive-area h-full w-full">
<YardManagement3DWidget
isEditMode={true}
config={element.yardConfig}
onConfigChange={(newConfig) => {
onUpdate(element.id, { yardConfig: newConfig });
}}
/>
</div>
) : element.type === "widget" && element.subtype === "todo" ? (
// To-Do 위젯 렌더링
<div className="widget-interactive-area h-full w-full">
<TodoWidget element={element} />
<TodoWidget />
</div>
) : element.type === "widget" && element.subtype === "booking-alert" ? (
// 예약 요청 알림 위젯 렌더링

View File

@ -98,7 +98,7 @@ export function ChartConfigPanel({
setDateColumns(schema.dateColumns);
})
.catch((error) => {
// console.error("❌ 테이블 스키마 조회 실패:", error);
console.error("❌ 테이블 스키마 조회 실패:", error);
// 실패 시 빈 배열 (날짜 필터 비활성화)
setDateColumns([]);
});

View File

@ -4,7 +4,6 @@ import React, { forwardRef, useState, useCallback, useMemo } from "react";
import { DashboardElement, ElementType, ElementSubtype, DragData } from "./types";
import { CanvasElement } from "./CanvasElement";
import { GRID_CONFIG, snapToGrid, calculateGridConfig } from "./gridUtils";
import { resolveAllCollisions } from "./collisionUtils";
interface DashboardCanvasProps {
elements: DashboardElement[];
@ -48,81 +47,6 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
const gridConfig = useMemo(() => calculateGridConfig(canvasWidth), [canvasWidth]);
const cellSize = gridConfig.CELL_SIZE;
// 충돌 방지 기능이 포함된 업데이트 핸들러
const handleUpdateWithCollisionDetection = useCallback(
(id: string, updates: Partial<DashboardElement>) => {
// position이나 size가 아닌 다른 속성 업데이트는 충돌 감지 없이 바로 처리
if (!updates.position && !updates.size) {
onUpdateElement(id, updates);
return;
}
// 업데이트할 요소 찾기
const elementIndex = elements.findIndex((el) => el.id === id);
if (elementIndex === -1) {
onUpdateElement(id, updates);
return;
}
// position이나 size와 다른 속성이 함께 있으면 분리해서 처리
const positionSizeUpdates: any = {};
const otherUpdates: any = {};
Object.keys(updates).forEach((key) => {
if (key === "position" || key === "size") {
positionSizeUpdates[key] = (updates as any)[key];
} else {
otherUpdates[key] = (updates as any)[key];
}
});
// 다른 속성들은 먼저 바로 업데이트
if (Object.keys(otherUpdates).length > 0) {
onUpdateElement(id, otherUpdates);
}
// position/size가 없으면 여기서 종료
if (Object.keys(positionSizeUpdates).length === 0) {
return;
}
// 임시로 업데이트된 요소 배열 생성
const updatedElements = elements.map((el) =>
el.id === id
? {
...el,
...positionSizeUpdates,
position: positionSizeUpdates.position || el.position,
size: positionSizeUpdates.size || el.size,
}
: el,
);
// 서브 그리드 크기 계산 (cellSize / 3)
const subGridSize = Math.floor(cellSize / 3);
// 충돌 해결 (서브 그리드 단위로 스냅 및 충돌 감지)
const resolvedElements = resolveAllCollisions(updatedElements, id, subGridSize, canvasWidth, cellSize);
// 변경된 요소들만 업데이트
resolvedElements.forEach((resolvedEl, idx) => {
const originalEl = elements[idx];
if (
resolvedEl.position.x !== originalEl.position.x ||
resolvedEl.position.y !== originalEl.position.y ||
resolvedEl.size.width !== originalEl.size.width ||
resolvedEl.size.height !== originalEl.size.height
) {
onUpdateElement(resolvedEl.id, {
position: resolvedEl.position,
size: resolvedEl.size,
});
}
});
},
[elements, onUpdateElement, cellSize, canvasWidth],
);
// 드래그 오버 처리
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
@ -155,21 +79,9 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
const rawX = e.clientX - rect.left + (ref.current?.scrollLeft || 0);
const rawY = e.clientY - rect.top + (ref.current?.scrollTop || 0);
// 마그네틱 스냅 (큰 그리드 우선, 없으면 서브그리드)
const subGridSize = Math.floor(cellSize / 3);
const gridSize = cellSize + 5; // GAP 포함한 실제 그리드 크기
const magneticThreshold = 15;
// X 좌표 스냅
const nearestGridX = Math.round(rawX / gridSize) * gridSize;
const distToGridX = Math.abs(rawX - nearestGridX);
let snappedX = distToGridX <= magneticThreshold ? nearestGridX : Math.round(rawX / subGridSize) * subGridSize;
// Y 좌표 스냅
const nearestGridY = Math.round(rawY / gridSize) * gridSize;
const distToGridY = Math.abs(rawY - nearestGridY);
const snappedY =
distToGridY <= magneticThreshold ? nearestGridY : Math.round(rawY / subGridSize) * subGridSize;
// 그리드에 스냅 (동적 셀 크기 사용)
let snappedX = snapToGrid(rawX, cellSize);
const snappedY = snapToGrid(rawY, cellSize);
// X 좌표가 캔버스 너비를 벗어나지 않도록 제한
const maxX = canvasWidth - cellSize * 2; // 최소 2칸 너비 보장
@ -249,7 +161,7 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
isSelected={selectedElement === element.id}
cellSize={cellSize}
canvasWidth={canvasWidth}
onUpdate={handleUpdateWithCollisionDetection}
onUpdate={onUpdateElement}
onRemove={onRemoveElement}
onSelect={onSelectElement}
onConfigure={onConfigureElement}

View File

@ -10,7 +10,6 @@ import { DashboardSaveModal } from "./DashboardSaveModal";
import { DashboardElement, ElementType, ElementSubtype } from "./types";
import { GRID_CONFIG, snapToGrid, snapSizeToGrid, calculateCellSize } from "./gridUtils";
import { Resolution, RESOLUTIONS, detectScreenResolution } from "./ResolutionSelector";
import { DashboardProvider } from "@/contexts/DashboardContext";
import { useMenu } from "@/contexts/MenuContext";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import {
@ -184,7 +183,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
(type: ElementType, subtype: ElementSubtype, x: number, y: number) => {
// 좌표 유효성 검사
if (isNaN(x) || isNaN(y)) {
// console.error("Invalid coordinates:", { x, y });
console.error("Invalid coordinates:", { x, y });
return;
}
@ -206,14 +205,14 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
// 크기 유효성 검사
if (isNaN(defaultWidth) || isNaN(defaultHeight) || defaultWidth <= 0 || defaultHeight <= 0) {
// console.error("Invalid size calculated:", {
// canvasConfig,
// cellSize,
// cellWithGap,
// defaultCells,
// defaultWidth,
// defaultHeight,
// });
console.error("Invalid size calculated:", {
canvasConfig,
cellSize,
cellWithGap,
defaultCells,
defaultWidth,
defaultHeight,
});
return;
}
@ -243,7 +242,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
// 좌표 유효성 확인
if (isNaN(centerX) || isNaN(centerY)) {
// console.error("Invalid canvas config:", canvasConfig);
console.error("Invalid canvas config:", canvasConfig);
return;
}
@ -345,7 +344,6 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
dataSource: el.dataSource,
chartConfig: el.chartConfig,
listConfig: el.listConfig,
yardConfig: el.yardConfig,
}));
let savedDashboard;
@ -444,128 +442,125 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
}
return (
<DashboardProvider>
<div className="flex h-full flex-col bg-gray-50">
{/* 상단 메뉴바 */}
<DashboardTopMenu
onSaveLayout={saveLayout}
onClearCanvas={clearCanvas}
dashboardTitle={dashboardTitle}
onAddElement={addElementFromMenu}
resolution={resolution}
onResolutionChange={handleResolutionChange}
currentScreenResolution={screenResolution}
backgroundColor={canvasBackgroundColor}
onBackgroundColorChange={setCanvasBackgroundColor}
/>
<div className="flex h-full flex-col bg-gray-50">
{/* 상단 메뉴바 */}
<DashboardTopMenu
onSaveLayout={saveLayout}
onClearCanvas={clearCanvas}
dashboardTitle={dashboardTitle}
onAddElement={addElementFromMenu}
resolution={resolution}
onResolutionChange={handleResolutionChange}
currentScreenResolution={screenResolution}
backgroundColor={canvasBackgroundColor}
onBackgroundColorChange={setCanvasBackgroundColor}
/>
{/* 캔버스 영역 - 해상도에 따른 크기, 중앙 정렬 */}
{/* overflow-auto 제거 - 외부 페이지 스크롤 사용 */}
<div className="flex flex-1 items-start justify-center bg-gray-100 p-8">
<div
className="relative shadow-2xl"
style={{
width: `${canvasConfig.width}px`,
minHeight: `${canvasConfig.height}px`,
}}
>
<DashboardCanvas
ref={canvasRef}
elements={elements}
selectedElement={selectedElement}
onCreateElement={createElement}
onUpdateElement={updateElement}
onRemoveElement={removeElement}
onSelectElement={setSelectedElement}
onConfigureElement={openConfigModal}
backgroundColor={canvasBackgroundColor}
canvasWidth={canvasConfig.width}
canvasHeight={dynamicCanvasHeight}
/>
</div>
</div>
{/* 요소 설정 모달 */}
{configModalElement && (
<>
{configModalElement.type === "widget" && configModalElement.subtype === "list" ? (
<ListWidgetConfigModal
element={configModalElement}
isOpen={true}
onClose={closeConfigModal}
onSave={saveListWidgetConfig}
/>
) : (
<ElementConfigModal
element={configModalElement}
isOpen={true}
onClose={closeConfigModal}
onSave={saveElementConfig}
/>
)}
</>
)}
{/* 저장 모달 */}
<DashboardSaveModal
isOpen={saveModalOpen}
onClose={() => setSaveModalOpen(false)}
onSave={handleSave}
initialTitle={dashboardTitle}
initialDescription={dashboardDescription}
isEditing={!!dashboardId}
/>
{/* 저장 성공 모달 */}
<Dialog
open={successModalOpen}
onOpenChange={() => {
setSuccessModalOpen(false);
router.push("/admin/dashboard");
{/* 캔버스 영역 - 해상도에 따른 크기, 중앙 정렬 */}
<div className="flex flex-1 items-start justify-center overflow-auto bg-gray-100 p-8">
<div
className="relative shadow-2xl"
style={{
width: `${canvasConfig.width}px`,
minHeight: `${canvasConfig.height}px`,
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
<CheckCircle2 className="h-6 w-6 text-green-600" />
</div>
<DialogTitle className="text-center"> </DialogTitle>
<DialogDescription className="text-center"> .</DialogDescription>
</DialogHeader>
<div className="flex justify-center pt-4">
<Button
onClick={() => {
setSuccessModalOpen(false);
router.push("/admin/dashboard");
}}
>
</Button>
</div>
</DialogContent>
</Dialog>
{/* 초기화 확인 모달 */}
<AlertDialog open={clearConfirmOpen} onOpenChange={setClearConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
. .
<br />
?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleClearConfirm} className="bg-red-600 hover:bg-red-700">
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<DashboardCanvas
ref={canvasRef}
elements={elements}
selectedElement={selectedElement}
onCreateElement={createElement}
onUpdateElement={updateElement}
onRemoveElement={removeElement}
onSelectElement={setSelectedElement}
onConfigureElement={openConfigModal}
backgroundColor={canvasBackgroundColor}
canvasWidth={canvasConfig.width}
canvasHeight={dynamicCanvasHeight}
/>
</div>
</div>
</DashboardProvider>
{/* 요소 설정 모달 */}
{configModalElement && (
<>
{configModalElement.type === "widget" && configModalElement.subtype === "list" ? (
<ListWidgetConfigModal
element={configModalElement}
isOpen={true}
onClose={closeConfigModal}
onSave={saveListWidgetConfig}
/>
) : (
<ElementConfigModal
element={configModalElement}
isOpen={true}
onClose={closeConfigModal}
onSave={saveElementConfig}
/>
)}
</>
)}
{/* 저장 모달 */}
<DashboardSaveModal
isOpen={saveModalOpen}
onClose={() => setSaveModalOpen(false)}
onSave={handleSave}
initialTitle={dashboardTitle}
initialDescription={dashboardDescription}
isEditing={!!dashboardId}
/>
{/* 저장 성공 모달 */}
<Dialog
open={successModalOpen}
onOpenChange={() => {
setSuccessModalOpen(false);
router.push("/admin/dashboard");
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
<CheckCircle2 className="h-6 w-6 text-green-600" />
</div>
<DialogTitle className="text-center"> </DialogTitle>
<DialogDescription className="text-center"> .</DialogDescription>
</DialogHeader>
<div className="flex justify-center pt-4">
<Button
onClick={() => {
setSuccessModalOpen(false);
router.push("/admin/dashboard");
}}
>
</Button>
</div>
</DialogContent>
</Dialog>
{/* 초기화 확인 모달 */}
<AlertDialog open={clearConfirmOpen} onOpenChange={setClearConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
. .
<br />
?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleClearConfirm} className="bg-red-600 hover:bg-red-700">
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@ -574,60 +569,38 @@ function getElementTitle(type: ElementType, subtype: ElementSubtype): string {
if (type === "chart") {
switch (subtype) {
case "bar":
return "바 차트";
return "📊 바 차트";
case "horizontal-bar":
return "수평 바 차트";
case "stacked-bar":
return "누적 바 차트";
return "📊 수평 바 차트";
case "pie":
return "원형 차트";
case "donut":
return "도넛 차트";
return "🥧 원형 차트";
case "line":
return "꺾은선 차트";
case "area":
return "영역 차트";
case "combo":
return "콤보 차트";
return "📈 꺾은선 차트";
default:
return "차트";
return "📊 차트";
}
} else if (type === "widget") {
switch (subtype) {
case "exchange":
return "환율 위젯";
return "💱 환율 위젯";
case "weather":
return "날씨 위젯";
return "☁️ 날씨 위젯";
case "clock":
return "시계 위젯";
return "시계 위젯";
case "calculator":
return "계산기 위젯";
return "🧮 계산기 위젯";
case "vehicle-map":
return "차량 위치 지도";
return "🚚 차량 위치 지도";
case "calendar":
return "달력 위젯";
return "📅 달력 위젯";
case "driver-management":
return "기사 관리 위젯";
return "🚚 기사 관리 위젯";
case "list":
return "리스트 위젯";
case "map-summary":
return "커스텀 지도 카드";
case "status-summary":
return "커스텀 상태 카드";
case "risk-alert":
return "리스크 알림 위젯";
case "todo":
return "할 일 위젯";
case "booking-alert":
return "예약 알림 위젯";
case "maintenance":
return "정비 일정 위젯";
case "document":
return "문서 위젯";
case "yard-management-3d":
return "야드 관리 3D";
return "📋 리스트 위젯";
case "warehouse-3d":
return "🏭 창고 현황 (3D)";
default:
return "위젯";
return "🔧 위젯";
}
}
return "요소";
@ -666,8 +639,8 @@ function getElementContent(type: ElementType, subtype: ElementSubtype): string {
return "driver-management";
case "list":
return "list-widget";
case "yard-management-3d":
return "yard-3d";
case "warehouse-3d":
return "warehouse-3d";
default:
return "위젯 내용이 여기에 표시됩니다";
}

View File

@ -83,7 +83,7 @@ export function DashboardSaveModal({
setAdminMenus(adminMenuList);
setUserMenus(userMenuList);
} catch (error) {
// console.error("메뉴 목록 로드 실패:", error);
console.error("메뉴 목록 로드 실패:", error);
setAdminMenus([]);
setUserMenus([]);
} finally {
@ -157,7 +157,7 @@ export function DashboardSaveModal({
});
onClose();
} catch (error) {
// console.error("저장 실패:", error);
console.error("저장 실패:", error);
} finally {
setLoading(false);
}

View File

@ -47,48 +47,56 @@ export function DashboardSidebar() {
{expandedSections.charts && (
<div className="space-y-2">
<DraggableItem
icon="📊"
title="바 차트"
type="chart"
subtype="bar"
onDragStart={handleDragStart}
/>
<DraggableItem
icon="📊"
title="수평 바 차트"
type="chart"
subtype="horizontal-bar"
onDragStart={handleDragStart}
/>
<DraggableItem
icon="📚"
title="누적 바 차트"
type="chart"
subtype="stacked-bar"
onDragStart={handleDragStart}
/>
<DraggableItem
icon="📈"
title="꺾은선 차트"
type="chart"
subtype="line"
onDragStart={handleDragStart}
/>
<DraggableItem
icon="📉"
title="영역 차트"
type="chart"
subtype="area"
onDragStart={handleDragStart}
/>
<DraggableItem
icon="🥧"
title="원형 차트"
type="chart"
subtype="pie"
onDragStart={handleDragStart}
/>
<DraggableItem
icon="🍩"
title="도넛 차트"
type="chart"
subtype="donut"
onDragStart={handleDragStart}
/>
<DraggableItem
icon="📊"
title="콤보 차트"
type="chart"
subtype="combo"
@ -115,60 +123,64 @@ export function DashboardSidebar() {
{expandedSections.widgets && (
<div className="space-y-2">
<DraggableItem
icon="💱"
title="환율 위젯"
type="widget"
subtype="exchange"
onDragStart={handleDragStart}
/>
<DraggableItem
icon="☁️"
title="날씨 위젯"
type="widget"
subtype="weather"
onDragStart={handleDragStart}
/>
<DraggableItem
icon="🧮"
title="계산기 위젯"
type="widget"
subtype="calculator"
onDragStart={handleDragStart}
/>
<DraggableItem
icon="⏰"
title="시계 위젯"
type="widget"
subtype="clock"
onDragStart={handleDragStart}
/>
<DraggableItem
icon="📍"
title="커스텀 지도 카드"
type="widget"
subtype="map-summary"
onDragStart={handleDragStart}
/>
{/* 주석: 다른 분이 범용 리스트 작업 중 - 충돌 방지를 위해 임시 주석처리 */}
{/* <DraggableItem
icon="📋"
title="커스텀 목록 카드"
type="widget"
subtype="list-summary"
onDragStart={handleDragStart}
/> */}
<DraggableItem
icon="⚠️"
title="리스크/알림 위젯"
type="widget"
subtype="risk-alert"
onDragStart={handleDragStart}
/>
<DraggableItem
title="To-Do / 긴급 지시"
type="widget"
subtype="todo"
onDragStart={handleDragStart}
/>
<DraggableItem
icon="📅"
title="달력 위젯"
type="widget"
subtype="calendar"
onDragStart={handleDragStart}
/>
<DraggableItem
icon="📊"
title="커스텀 상태 카드"
type="widget"
subtype="status-summary"
@ -195,12 +207,14 @@ export function DashboardSidebar() {
{expandedSections.operations && (
<div className="space-y-2">
<DraggableItem
icon="✅"
title="To-Do / 긴급 지시"
type="widget"
subtype="todo"
onDragStart={handleDragStart}
/>
<DraggableItem
icon="🔔"
title="예약 요청 알림"
type="widget"
subtype="booking-alert"
@ -208,12 +222,14 @@ export function DashboardSidebar() {
/>
{/* 정비 일정 관리 위젯 제거 - 커스텀 목록 카드로 대체 가능 */}
<DraggableItem
icon="📂"
title="문서 다운로드"
type="widget"
subtype="document"
onDragStart={handleDragStart}
/>
<DraggableItem
icon="📋"
title="리스트 위젯"
type="widget"
subtype="list"
@ -227,7 +243,7 @@ export function DashboardSidebar() {
}
interface DraggableItemProps {
icon?: string;
icon: string;
title: string;
type: ElementType;
subtype: ElementSubtype;
@ -238,7 +254,7 @@ interface DraggableItemProps {
/**
*
*/
function DraggableItem({ title, type, subtype, className = "", onDragStart }: DraggableItemProps) {
function DraggableItem({ icon, title, type, subtype, className = "", onDragStart }: DraggableItemProps) {
return (
<div
draggable

View File

@ -181,11 +181,8 @@ export function DashboardTopMenu({
<SelectGroup>
<SelectLabel> </SelectLabel>
<SelectItem value="list"> </SelectItem>
<SelectItem value="yard-management-3d"> 3D</SelectItem>
{/* <SelectItem value="map">지도</SelectItem> */}
<SelectItem value="map-summary"> </SelectItem>
{/* <SelectItem value="list-summary">커스텀 목록 카드</SelectItem> */}
<SelectItem value="status-summary"> </SelectItem>
<SelectItem value="map"></SelectItem>
<SelectItem value="warehouse-3d"> (3D)</SelectItem>
</SelectGroup>
<SelectGroup>
<SelectLabel> </SelectLabel>
@ -200,13 +197,12 @@ export function DashboardTopMenu({
<SelectItem value="document"></SelectItem>
<SelectItem value="risk-alert"> </SelectItem>
</SelectGroup>
{/* 범용 위젯으로 대체 가능하여 주석처리 */}
{/* <SelectGroup>
<SelectGroup>
<SelectLabel> </SelectLabel>
<SelectItem value="vehicle-status"> </SelectItem>
<SelectItem value="vehicle-list"> </SelectItem>
<SelectItem value="vehicle-map"> </SelectItem>
</SelectGroup> */}
</SelectGroup>
</SelectContent>
</Select>
</div>

View File

@ -98,7 +98,7 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
setQueryResult(result);
// 쿼리가 변경되었으므로 차트 설정 초기화 (X/Y축 리셋)
// console.log("🔄 쿼리 변경 감지 - 차트 설정 초기화");
console.log("🔄 쿼리 변경 감지 - 차트 설정 초기화");
setChartConfig({});
}, []);
@ -126,7 +126,7 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
showHeader, // 헤더 표시 여부
};
// console.log(" 저장할 element:", updatedElement);
console.log(" 저장할 element:", updatedElement);
onSave(updatedElement);
onClose();
@ -135,11 +135,11 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
// 모달이 열려있지 않으면 렌더링하지 않음
if (!isOpen) return null;
// 시계, 달력, To-Do 위젯은 헤더 설정만 가능
const isHeaderOnlyWidget = element.type === "widget" && (element.subtype === "clock" || element.subtype === "calendar" || element.subtype === "todo");
// 기사관리 위젯은 자체 설정 UI를 가지고 있으므로 모달 표시하지 않음
if (element.type === "widget" && element.subtype === "driver-management") {
// 시계, 달력, 기사관리 위젯은 자체 설정 UI를 가지고 있으므로 모달 표시하지 않음
if (
element.type === "widget" &&
(element.subtype === "clock" || element.subtype === "calendar" || element.subtype === "driver-management")
) {
return null;
}
@ -212,18 +212,16 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
type="text"
value={customTitle}
onChange={(e) => setCustomTitle(e.target.value)}
onKeyDown={(e) => {
// 모든 키보드 이벤트를 input 필드 내부에서만 처리
e.stopPropagation();
}}
placeholder="예: 정비 일정 목록, 창고 위치 현황 등 (비워두면 자동 생성)"
placeholder={"예: 정비 일정 목록, 창고 위치 현황 등 (비워두면 자동 생성)"}
className="focus:border-primary focus:ring-primary w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:ring-1 focus:outline-none"
/>
<p className="mt-1 text-xs text-gray-500"> (: "maintenance_schedules 목록")</p>
<p className="mt-1 text-xs text-gray-500">
💡 (: "maintenance_schedules 목록")
</p>
</div>
{/* 헤더 표시 옵션 */}
<div className="mt-3 flex items-center gap-2">
{/* 헤더 표시 여부 */}
<div className="mt-4 flex items-center">
<input
type="checkbox"
id="showHeader"
@ -231,14 +229,14 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
onChange={(e) => setShowHeader(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary"
/>
<label htmlFor="showHeader" className="text-sm font-medium text-gray-700">
( + )
<label htmlFor="showHeader" className="ml-2 block text-sm text-gray-700">
</label>
</div>
</div>
{/* 진행 상황 표시 - 간단한 위젯과 헤더 전용 위젯은 표시 안 함 */}
{!isSimpleWidget && !isHeaderOnlyWidget && (
{/* 진행 상황 표시 - 간단한 위젯은 표시 안 함 */}
{!isSimpleWidget && (
<div className="border-b bg-gray-50 px-6 py-4">
<div className="flex items-center justify-between">
<div className="text-sm font-medium text-gray-700">
@ -249,13 +247,12 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
)}
{/* 단계별 내용 */}
{!isHeaderOnlyWidget && (
<div className="flex-1 overflow-auto p-6">
{currentStep === 1 && (
<DataSourceSelector dataSource={dataSource} onTypeChange={handleDataSourceTypeChange} />
)}
<div className="flex-1 overflow-auto p-6">
{currentStep === 1 && (
<DataSourceSelector dataSource={dataSource} onTypeChange={handleDataSourceTypeChange} />
)}
{currentStep === 2 && (
{currentStep === 2 && (
<div className={`grid ${isSimpleWidget ? "grid-cols-1" : "grid-cols-2"} gap-6`}>
{/* 왼쪽: 데이터 설정 */}
<div className="space-y-6">
@ -311,16 +308,15 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
</div>
)}
</div>
)}
</div>
)}
)}
</div>
{/* 모달 푸터 */}
<div className="flex items-center justify-between border-t bg-gray-50 p-6">
<div>{queryResult && <Badge variant="default">{queryResult.rows.length} </Badge>}</div>
<div className="flex gap-3">
{!isSimpleWidget && !isHeaderOnlyWidget && currentStep > 1 && (
{!isSimpleWidget && currentStep > 1 && (
<Button variant="outline" onClick={handlePrev}>
<ChevronLeft className="mr-2 h-4 w-4" />
@ -329,20 +325,14 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
<Button variant="outline" onClick={onClose}>
</Button>
{isHeaderOnlyWidget ? (
// 헤더 전용 위젯: 바로 저장
<Button onClick={handleSave}>
<Save className="mr-2 h-4 w-4" />
</Button>
) : currentStep === 1 ? (
// 1단계: 다음 버튼
{currentStep === 1 ? (
// 1단계: 다음 버튼 (모든 타입 공통)
<Button onClick={handleNext}>
<ChevronRight className="ml-2 h-4 w-4" />
</Button>
) : (
// 2단계: 저장 버튼
// 2단계: 저장 버튼 (모든 타입 공통)
<Button onClick={handleSave} disabled={!canSave}>
<Save className="mr-2 h-4 w-4" />

View File

@ -61,7 +61,7 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
setUserMenus(userResponse.data || []);
}
} catch (error) {
// console.error("메뉴 목록 로드 실패:", error);
console.error("메뉴 목록 로드 실패:", error);
toast.error("메뉴 목록을 불러오는데 실패했습니다.");
} finally {
setLoading(false);

View File

@ -35,9 +35,9 @@ export function QueryEditor({ dataSource, onDataSourceChange, onQueryTest }: Que
// 쿼리 실행
const executeQuery = useCallback(async () => {
// console.log("🚀 executeQuery 호출됨!");
// console.log("📝 현재 쿼리:", query);
// console.log("✅ query.trim():", query.trim());
console.log("🚀 executeQuery 호출됨!");
console.log("📝 현재 쿼리:", query);
console.log("✅ query.trim():", query.trim());
if (!query.trim()) {
setError("쿼리를 입력해주세요.");
@ -47,13 +47,13 @@ export function QueryEditor({ dataSource, onDataSourceChange, onQueryTest }: Que
// 외부 DB인 경우 커넥션 ID 확인
if (dataSource?.connectionType === "external" && !dataSource?.externalConnectionId) {
setError("외부 DB 커넥션을 선택해주세요.");
// console.log("❌ 쿼리가 비어있음!");
console.log("❌ 쿼리가 비어있음!");
return;
}
setIsExecuting(true);
setError(null);
// console.log("🔄 쿼리 실행 시작...");
console.log("🔄 쿼리 실행 시작...");
try {
let apiResult: { columns: string[]; rows: any[]; rowCount: number };
@ -247,7 +247,7 @@ ORDER BY Q4 DESC;`,
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent className="z-[99999]">
<SelectContent>
<SelectItem value="0"></SelectItem>
<SelectItem value="10000">10</SelectItem>
<SelectItem value="30000">30</SelectItem>

View File

@ -1,162 +0,0 @@
/**
*
*/
import { DashboardElement } from "./types";
export interface Rectangle {
x: number;
y: number;
width: number;
height: number;
}
/**
* ( )
* @param rect1
* @param rect2
* @param cellSize (기본: 130px)
*/
export function isColliding(rect1: Rectangle, rect2: Rectangle, cellSize: number = 130): boolean {
// 겹친 영역 계산
const overlapX = Math.max(
0,
Math.min(rect1.x + rect1.width, rect2.x + rect2.width) - Math.max(rect1.x, rect2.x)
);
const overlapY = Math.max(
0,
Math.min(rect1.y + rect1.height, rect2.y + rect2.height) - Math.max(rect1.y, rect2.y)
);
// 큰 그리드의 절반(cellSize/2 ≈ 65px) 이상 겹쳐야 충돌로 간주
const collisionThreshold = Math.floor(cellSize / 2);
return overlapX >= collisionThreshold && overlapY >= collisionThreshold;
}
/**
*
*/
export function findCollisions(
element: DashboardElement,
allElements: DashboardElement[],
cellSize: number = 130,
excludeId?: string
): DashboardElement[] {
const elementRect: Rectangle = {
x: element.position.x,
y: element.position.y,
width: element.size.width,
height: element.size.height,
};
return allElements.filter((other) => {
if (other.id === element.id || other.id === excludeId) {
return false;
}
const otherRect: Rectangle = {
x: other.position.x,
y: other.position.y,
width: other.size.width,
height: other.size.height,
};
return isColliding(elementRect, otherRect, cellSize);
});
}
/**
*
*/
export function resolveCollisionVertically(
movingElement: DashboardElement,
collidingElement: DashboardElement,
gridSize: number = 10
): { x: number; y: number } {
// 충돌하는 위젯 아래로 이동
const newY = collidingElement.position.y + collidingElement.size.height + gridSize;
return {
x: collidingElement.position.x,
y: Math.round(newY / gridSize) * gridSize, // 그리드에 스냅
};
}
/**
*
*/
export function resolveAllCollisions(
elements: DashboardElement[],
movedElementId: string,
subGridSize: number = 10,
canvasWidth: number = 1560,
cellSize: number = 130,
maxIterations: number = 50
): DashboardElement[] {
let result = [...elements];
let iterations = 0;
// 이동한 위젯부터 시작
const movedIndex = result.findIndex((el) => el.id === movedElementId);
if (movedIndex === -1) return result;
// Y 좌표로 정렬 (위에서 아래로 처리)
const sortedIndices = result
.map((el, idx) => ({ el, idx }))
.sort((a, b) => a.el.position.y - b.el.position.y)
.map((item) => item.idx);
while (iterations < maxIterations) {
let hasCollision = false;
for (const idx of sortedIndices) {
const element = result[idx];
const collisions = findCollisions(element, result, cellSize);
if (collisions.length > 0) {
hasCollision = true;
// 첫 번째 충돌만 처리 (가장 위에 있는 것)
const collision = collisions.sort((a, b) => a.position.y - b.position.y)[0];
// 충돌하는 위젯을 아래로 이동
const collisionIdx = result.findIndex((el) => el.id === collision.id);
if (collisionIdx !== -1) {
const newY = element.position.y + element.size.height + subGridSize;
result[collisionIdx] = {
...result[collisionIdx],
position: {
...result[collisionIdx].position,
y: Math.round(newY / subGridSize) * subGridSize,
},
};
}
}
}
if (!hasCollision) break;
iterations++;
}
return result;
}
/**
*
*/
export function constrainToCanvas(
element: DashboardElement,
canvasWidth: number,
canvasHeight: number,
gridSize: number = 10
): { x: number; y: number } {
const maxX = canvasWidth - element.size.width;
const maxY = canvasHeight - element.size.height;
return {
x: Math.max(0, Math.min(Math.round(element.position.x / gridSize) * gridSize, maxX)),
y: Math.max(0, Math.min(Math.round(element.position.y / gridSize) * gridSize, maxY)),
};
}

View File

@ -8,10 +8,9 @@
// 기본 그리드 설정 (FHD 기준)
export const GRID_CONFIG = {
COLUMNS: 12, // 모든 해상도에서 12칸 고정
GAP: 5, // 셀 간격 고정
SNAP_THRESHOLD: 10, // 스냅 임계값 (px)
GAP: 8, // 셀 간격 고정
SNAP_THRESHOLD: 15, // 스냅 임계값 (px)
ELEMENT_PADDING: 4, // 요소 주위 여백 (px)
SUB_GRID_DIVISIONS: 5, // 각 그리드 칸을 5x5로 세분화 (세밀한 조정용)
// CELL_SIZE와 CANVAS_WIDTH는 해상도에 따라 동적 계산
} as const;
@ -24,23 +23,14 @@ export function calculateCellSize(canvasWidth: number): number {
return Math.floor((canvasWidth + GRID_CONFIG.GAP) / GRID_CONFIG.COLUMNS) - GRID_CONFIG.GAP;
}
/**
* ( )
*/
export function calculateSubGridSize(cellSize: number): number {
return Math.floor(cellSize / GRID_CONFIG.SUB_GRID_DIVISIONS);
}
/**
*
*/
export function calculateGridConfig(canvasWidth: number) {
const cellSize = calculateCellSize(canvasWidth);
const subGridSize = calculateSubGridSize(cellSize);
return {
...GRID_CONFIG,
CELL_SIZE: cellSize,
SUB_GRID_SIZE: subGridSize,
CANVAS_WIDTH: canvasWidth,
};
}
@ -61,18 +51,15 @@ export const getCanvasWidth = () => {
};
/**
* ( )
* ( )
* @param value -
* @param subGridSize - (, 기본값: cellSize/3 43px)
* @returns
* @param cellSize - (, GRID_CONFIG.CELL_SIZE)
* @returns ( )
*/
export const snapToGrid = (value: number, subGridSize?: number): number => {
// 서브 그리드 크기가 지정되지 않으면 기본 그리드 크기의 1/3 사용 (3x3 서브그리드)
const snapSize = subGridSize ?? Math.floor(GRID_CONFIG.CELL_SIZE / 3);
// 서브 그리드 단위로 스냅
const gridIndex = Math.round(value / snapSize);
return gridIndex * snapSize;
export const snapToGrid = (value: number, cellSize: number = GRID_CONFIG.CELL_SIZE): number => {
const cellWithGap = cellSize + GRID_CONFIG.GAP;
const gridIndex = Math.round(value / cellWithGap);
return gridIndex * cellWithGap + GRID_CONFIG.ELEMENT_PADDING;
};
/**

View File

@ -36,7 +36,7 @@ export type ElementSubtype =
| "maintenance"
| "document"
| "list"
| "yard-management-3d"; // 야드 관리 3D 위젯
| "warehouse-3d"; // 위젯 타입
export interface Position {
x: number;
@ -64,7 +64,6 @@ export interface DashboardElement {
calendarConfig?: CalendarConfig; // 달력 설정
driverManagementConfig?: DriverManagementConfig; // 기사 관리 설정
listConfig?: ListWidgetConfig; // 리스트 위젯 설정
yardConfig?: YardManagementConfig; // 야드 관리 3D 설정
}
export interface DragData {
@ -273,9 +272,3 @@ export interface ListColumn {
align?: "left" | "center" | "right"; // 정렬
visible?: boolean; // 표시 여부 (기본: true)
}
// 야드 관리 3D 설정
export interface YardManagementConfig {
layoutId: number; // 선택된 야드 레이아웃 ID
layoutName?: string; // 레이아웃 이름 (표시용)
}

View File

@ -8,7 +8,6 @@ import { generateCalendarDays, getMonthName, navigateMonth } from "./calendarUti
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import { Settings, ChevronLeft, ChevronRight, Calendar } from "lucide-react";
import { useDashboard } from "@/contexts/DashboardContext";
interface CalendarWidgetProps {
element: DashboardElement;
@ -22,19 +21,11 @@ interface CalendarWidgetProps {
* - UI
*/
export function CalendarWidget({ element, onConfigUpdate }: CalendarWidgetProps) {
// Context에서 선택된 날짜 관리
const { selectedDate, setSelectedDate } = useDashboard();
// 현재 표시 중인 년/월
const today = new Date();
const [currentYear, setCurrentYear] = useState(today.getFullYear());
const [currentMonth, setCurrentMonth] = useState(today.getMonth());
const [settingsOpen, setSettingsOpen] = useState(false);
// 날짜 클릭 핸들러
const handleDateClick = (date: Date) => {
setSelectedDate(date);
};
// 기본 설정값
const config = element.calendarConfig || {
@ -107,15 +98,7 @@ export function CalendarWidget({ element, onConfigUpdate }: CalendarWidgetProps)
{/* 달력 콘텐츠 */}
<div className="flex-1 overflow-hidden">
{config.view === "month" && (
<MonthView
days={calendarDays}
config={config}
isCompact={isCompact}
selectedDate={selectedDate}
onDateClick={handleDateClick}
/>
)}
{config.view === "month" && <MonthView days={calendarDays} config={config} isCompact={isCompact} />}
{/* 추후 WeekView, DayView 추가 가능 */}
</div>

View File

@ -219,9 +219,8 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
return (
<div className="flex h-full w-full flex-col p-4">
{/* 제목 - 항상 표시 */}
<div className="mb-4">
<h3 className="text-sm font-semibold text-gray-700">{element.customTitle || element.title}</h3>
<h3 className="text-sm font-semibold text-gray-700">{element.title}</h3>
</div>
{/* 테이블 뷰 */}

View File

@ -131,7 +131,7 @@ export function ListWidgetConfigModal({ isOpen, element, onClose, onSave }: List
// 저장
const handleSave = () => {
onSave({
customTitle: title,
title,
dataSource,
listConfig,
});
@ -166,19 +166,10 @@ export function ListWidgetConfigModal({ isOpen, element, onClose, onSave }: List
id="list-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
onKeyDown={(e) => {
// 모든 키보드 이벤트를 input 필드 내부에서만 처리
e.stopPropagation();
}}
placeholder="예: 사용자 목록"
className="mt-1"
/>
</div>
{/* 참고: 리스트 위젯은 제목이 항상 표시됩니다 */}
<div className="rounded bg-blue-50 p-2 text-xs text-blue-700">
💡
</div>
</div>
{/* 진행 상태 표시 */}

View File

@ -7,14 +7,12 @@ interface MonthViewProps {
days: CalendarDay[];
config: CalendarConfig;
isCompact?: boolean; // 작은 크기 (2x2, 3x3)
selectedDate?: Date | null; // 선택된 날짜
onDateClick?: (date: Date) => void; // 날짜 클릭 핸들러
}
/**
*
*/
export function MonthView({ days, config, isCompact = false, selectedDate, onDateClick }: MonthViewProps) {
export function MonthView({ days, config, isCompact = false }: MonthViewProps) {
const weekDayNames = getWeekDayNames(config.startWeekOn);
// 테마별 스타일
@ -45,27 +43,10 @@ export function MonthView({ days, config, isCompact = false, selectedDate, onDat
const themeStyles = getThemeStyles();
// 날짜가 선택된 날짜인지 확인
const isSelected = (day: CalendarDay) => {
if (!selectedDate || !day.isCurrentMonth) return false;
return (
selectedDate.getFullYear() === day.date.getFullYear() &&
selectedDate.getMonth() === day.date.getMonth() &&
selectedDate.getDate() === day.date.getDate()
);
};
// 날짜 클릭 핸들러
const handleDayClick = (day: CalendarDay) => {
if (!day.isCurrentMonth || !onDateClick) return;
onDateClick(day.date);
};
// 날짜 셀 스타일 클래스
const getDayCellClass = (day: CalendarDay) => {
const baseClass = "flex aspect-square items-center justify-center rounded-lg transition-colors";
const sizeClass = isCompact ? "text-xs" : "text-sm";
const cursorClass = day.isCurrentMonth ? "cursor-pointer" : "cursor-default";
let colorClass = "text-gray-700";
@ -73,10 +54,6 @@ export function MonthView({ days, config, isCompact = false, selectedDate, onDat
if (!day.isCurrentMonth) {
colorClass = "text-gray-300";
}
// 선택된 날짜
else if (isSelected(day)) {
colorClass = "text-white font-bold";
}
// 오늘
else if (config.highlightToday && day.isToday) {
colorClass = "text-white font-bold";
@ -90,16 +67,9 @@ export function MonthView({ days, config, isCompact = false, selectedDate, onDat
colorClass = "text-red-600";
}
let bgClass = "";
if (isSelected(day)) {
bgClass = ""; // 선택된 날짜는 배경색이 style로 적용됨
} else if (config.highlightToday && day.isToday) {
bgClass = "";
} else {
bgClass = "hover:bg-gray-100";
}
const bgClass = config.highlightToday && day.isToday ? "" : "hover:bg-gray-100";
return `${baseClass} ${sizeClass} ${colorClass} ${bgClass} ${cursorClass}`;
return `${baseClass} ${sizeClass} ${colorClass} ${bgClass}`;
};
return (
@ -127,13 +97,9 @@ export function MonthView({ days, config, isCompact = false, selectedDate, onDat
<div
key={index}
className={getDayCellClass(day)}
onClick={() => handleDayClick(day)}
style={{
backgroundColor: isSelected(day)
? "#10b981" // 선택된 날짜는 초록색
: config.highlightToday && day.isToday
? themeStyles.todayBg
: undefined,
backgroundColor:
config.highlightToday && day.isToday ? themeStyles.todayBg : undefined,
color:
config.showHolidays && day.isHoliday && day.isCurrentMonth
? themeStyles.holidayText

View File

@ -1,336 +0,0 @@
"use client";
import React, { useState, useCallback, useEffect } from "react";
import { DashboardElement, ChartDataSource, QueryResult } from "../types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ChevronLeft, ChevronRight, Save, X } from "lucide-react";
import { DataSourceSelector } from "../data-sources/DataSourceSelector";
import { DatabaseConfig } from "../data-sources/DatabaseConfig";
import { ApiConfig } from "../data-sources/ApiConfig";
import { QueryEditor } from "../QueryEditor";
interface TodoWidgetConfigModalProps {
isOpen: boolean;
element: DashboardElement;
onClose: () => void;
onSave: (updates: Partial<DashboardElement>) => void;
}
/**
* To-Do
* - 2 설정: 데이터 /
*/
export function TodoWidgetConfigModal({ isOpen, element, onClose, onSave }: TodoWidgetConfigModalProps) {
const [currentStep, setCurrentStep] = useState<1 | 2>(1);
const [title, setTitle] = useState(element.title || "✅ To-Do / 긴급 지시");
const [dataSource, setDataSource] = useState<ChartDataSource>(
element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 },
);
const [queryResult, setQueryResult] = useState<QueryResult | null>(null);
// 모달 열릴 때 element에서 설정 로드
useEffect(() => {
if (isOpen) {
setTitle(element.title || "✅ To-Do / 긴급 지시");
if (element.dataSource) {
setDataSource(element.dataSource);
}
setCurrentStep(1);
}
}, [isOpen, element.id]);
// 데이터 소스 타입 변경
const handleDataSourceTypeChange = useCallback((type: "database" | "api") => {
if (type === "database") {
setDataSource((prev) => ({
...prev,
type: "database",
connectionType: "current",
}));
} else {
setDataSource((prev) => ({
...prev,
type: "api",
method: "GET",
}));
}
setQueryResult(null);
}, []);
// 데이터 소스 업데이트
const handleDataSourceUpdate = useCallback((updates: Partial<ChartDataSource>) => {
setDataSource((prev) => ({ ...prev, ...updates }));
}, []);
// 쿼리 실행 결과 처리
const handleQueryTest = useCallback(
(result: QueryResult) => {
// console.log("🎯 TodoWidget - handleQueryTest 호출됨!");
// console.log("📊 쿼리 결과:", result);
// console.log("📝 rows 개수:", result.rows?.length);
// console.log("❌ error:", result.error);
setQueryResult(result);
// console.log("✅ setQueryResult 호출 완료!");
// 강제 리렌더링 확인
// setTimeout(() => {
// console.log("🔄 1초 후 queryResult 상태:", result);
// }, 1000);
},
[],
);
// 저장
const handleSave = useCallback(() => {
if (!dataSource.query || !queryResult || queryResult.error) {
alert("쿼리를 입력하고 테스트를 먼저 실행해주세요.");
return;
}
if (!queryResult.rows || queryResult.rows.length === 0) {
alert("쿼리 결과가 없습니다. 데이터가 반환되는 쿼리를 입력해주세요.");
return;
}
onSave({
title,
dataSource,
});
onClose();
}, [title, dataSource, queryResult, onSave, onClose]);
// 다음 단계로
const handleNext = useCallback(() => {
if (currentStep === 1) {
if (dataSource.type === "database") {
if (!dataSource.connectionId && dataSource.connectionType === "external") {
alert("외부 데이터베이스를 선택해주세요.");
return;
}
} else if (dataSource.type === "api") {
if (!dataSource.url) {
alert("API URL을 입력해주세요.");
return;
}
}
setCurrentStep(2);
}
}, [currentStep, dataSource]);
// 이전 단계로
const handlePrev = useCallback(() => {
if (currentStep === 2) {
setCurrentStep(1);
}
}, [currentStep]);
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50">
<div className="relative flex h-[90vh] w-[90vw] max-w-6xl flex-col rounded-lg bg-white shadow-xl">
{/* 헤더 */}
<div className="flex items-center justify-between border-b border-gray-200 px-6 py-4">
<div>
<h2 className="text-xl font-bold text-gray-800">To-Do </h2>
<p className="mt-1 text-sm text-gray-500">
To-Do
</p>
</div>
<button
onClick={onClose}
className="rounded-lg p-2 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700"
>
<X className="h-5 w-5" />
</button>
</div>
{/* 진행 상태 */}
<div className="border-b border-gray-200 bg-gray-50 px-6 py-3">
<div className="flex items-center gap-4">
<div className={`flex items-center gap-2 ${currentStep === 1 ? "text-primary" : "text-gray-400"}`}>
<div
className={`flex h-8 w-8 items-center justify-center rounded-full font-semibold ${
currentStep === 1 ? "bg-primary text-white" : "bg-gray-200"
}`}
>
1
</div>
<span className="font-medium"> </span>
</div>
<ChevronRight className="h-4 w-4 text-gray-400" />
<div className={`flex items-center gap-2 ${currentStep === 2 ? "text-primary" : "text-gray-400"}`}>
<div
className={`flex h-8 w-8 items-center justify-center rounded-full font-semibold ${
currentStep === 2 ? "bg-primary text-white" : "bg-gray-200"
}`}
>
2
</div>
<span className="font-medium"> </span>
</div>
</div>
</div>
{/* 본문 */}
<div className="flex-1 overflow-y-auto p-6">
{/* Step 1: 데이터 소스 선택 */}
{currentStep === 1 && (
<div className="space-y-6">
<div>
<Label className="text-base font-semibold"></Label>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="예: ✅ 오늘의 할 일"
className="mt-2"
/>
</div>
<div>
<Label className="text-base font-semibold"> </Label>
<DataSourceSelector
dataSource={dataSource}
onTypeChange={handleDataSourceTypeChange}
/>
</div>
{dataSource.type === "database" && (
<DatabaseConfig dataSource={dataSource} onUpdate={handleDataSourceUpdate} />
)}
{dataSource.type === "api" && <ApiConfig dataSource={dataSource} onUpdate={handleDataSourceUpdate} />}
</div>
)}
{/* Step 2: 쿼리 입력 및 테스트 */}
{currentStep === 2 && (
<div className="space-y-6">
<div>
<div className="mb-4 rounded-lg bg-blue-50 p-4">
<h3 className="mb-2 font-semibold text-blue-900">💡 </h3>
<p className="mb-2 text-sm text-blue-700">
To-Do :
</p>
<ul className="space-y-1 text-sm text-blue-600">
<li>
<code className="rounded bg-blue-100 px-1 py-0.5">id</code> - ID ( )
</li>
<li>
<code className="rounded bg-blue-100 px-1 py-0.5">title</code>,{" "}
<code className="rounded bg-blue-100 px-1 py-0.5">task</code>,{" "}
<code className="rounded bg-blue-100 px-1 py-0.5">name</code> - ()
</li>
<li>
<code className="rounded bg-blue-100 px-1 py-0.5">description</code>,{" "}
<code className="rounded bg-blue-100 px-1 py-0.5">desc</code>,{" "}
<code className="rounded bg-blue-100 px-1 py-0.5">content</code> -
</li>
<li>
<code className="rounded bg-blue-100 px-1 py-0.5">priority</code> - (urgent, high,
normal, low)
</li>
<li>
<code className="rounded bg-blue-100 px-1 py-0.5">status</code> - (pending, in_progress,
completed)
</li>
<li>
<code className="rounded bg-blue-100 px-1 py-0.5">assigned_to</code>,{" "}
<code className="rounded bg-blue-100 px-1 py-0.5">assignedTo</code>,{" "}
<code className="rounded bg-blue-100 px-1 py-0.5">user</code> -
</li>
<li>
<code className="rounded bg-blue-100 px-1 py-0.5">due_date</code>,{" "}
<code className="rounded bg-blue-100 px-1 py-0.5">dueDate</code>,{" "}
<code className="rounded bg-blue-100 px-1 py-0.5">deadline</code> -
</li>
<li>
<code className="rounded bg-blue-100 px-1 py-0.5">is_urgent</code>,{" "}
<code className="rounded bg-blue-100 px-1 py-0.5">isUrgent</code>,{" "}
<code className="rounded bg-blue-100 px-1 py-0.5">urgent</code> -
</li>
</ul>
</div>
<QueryEditor
dataSource={dataSource}
onDataSourceChange={handleDataSourceUpdate}
onQueryTest={handleQueryTest}
/>
</div>
{/* 디버그: 항상 표시되는 테스트 메시지 */}
<div className="mt-4 rounded-lg bg-yellow-50 border-2 border-yellow-500 p-4">
<p className="text-sm font-bold text-yellow-900">
🔍 디버그: queryResult = {queryResult ? "있음" : "없음"}
</p>
{queryResult && (
<p className="text-xs text-yellow-700 mt-1">
rows: {queryResult.rows?.length}, error: {queryResult.error || "없음"}
</p>
)}
</div>
{queryResult && !queryResult.error && queryResult.rows && queryResult.rows.length > 0 && (
<div className="mt-4 rounded-lg bg-green-50 border-2 border-green-500 p-4">
<h3 className="mb-2 font-semibold text-green-900"> !</h3>
<p className="text-sm text-green-700">
<strong>{queryResult.rows.length}</strong> To-Do .
</p>
<div className="mt-3 rounded bg-white p-3">
<p className="mb-2 text-xs font-semibold text-gray-600"> :</p>
<pre className="overflow-x-auto text-xs text-gray-700">
{JSON.stringify(queryResult.rows[0], null, 2)}
</pre>
</div>
</div>
)}
</div>
)}
</div>
{/* 하단 버튼 */}
<div className="flex items-center justify-between border-t border-gray-200 px-6 py-4">
<div>
{currentStep > 1 && (
<Button onClick={handlePrev} variant="outline">
<ChevronLeft className="mr-1 h-4 w-4" />
</Button>
)}
</div>
<div className="flex gap-2">
<Button onClick={onClose} variant="outline">
</Button>
{currentStep < 2 ? (
<Button onClick={handleNext}>
<ChevronRight className="ml-1 h-4 w-4" />
</Button>
) : (
<Button
onClick={handleSave}
disabled={(() => {
const isDisabled = !queryResult || queryResult.error || !queryResult.rows || queryResult.rows.length === 0;
// console.log("💾 저장 버튼 disabled:", isDisabled);
// console.log("💾 queryResult:", queryResult);
return isDisabled;
})()}
>
<Save className="mr-1 h-4 w-4" />
</Button>
)}
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,418 @@
"use client";
import React, { useRef, useState, useEffect, Suspense } from "react";
import { Canvas, useFrame } from "@react-three/fiber";
import { OrbitControls, Text, Box, Html } from "@react-three/drei";
import * as THREE from "three";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Loader2, Maximize2, Info } from "lucide-react";
interface WarehouseData {
id: string;
name: string;
position_x: number;
position_y: number;
position_z: number;
size_x: number;
size_y: number;
size_z: number;
color: string;
capacity: number;
current_usage: number;
status: string;
description?: string;
}
interface MaterialData {
id: string;
warehouse_id: string;
name: string;
material_code: string;
quantity: number;
unit: string;
position_x: number;
position_y: number;
position_z: number;
size_x: number;
size_y: number;
size_z: number;
color: string;
status: string;
}
interface Warehouse3DWidgetProps {
element?: any;
}
// 창고 3D 박스 컴포넌트
function WarehouseBox({
warehouse,
onClick,
isSelected,
}: {
warehouse: WarehouseData;
onClick: () => void;
isSelected: boolean;
}) {
const meshRef = useRef<THREE.Mesh>(null);
const [hovered, setHovered] = useState(false);
useFrame(() => {
if (meshRef.current) {
if (isSelected) {
meshRef.current.scale.lerp(new THREE.Vector3(1.05, 1.05, 1.05), 0.1);
} else if (hovered) {
meshRef.current.scale.lerp(new THREE.Vector3(1.02, 1.02, 1.02), 0.1);
} else {
meshRef.current.scale.lerp(new THREE.Vector3(1, 1, 1), 0.1);
}
}
});
const usagePercentage = (warehouse.current_usage / warehouse.capacity) * 100;
return (
<group position={[warehouse.position_x, warehouse.position_y + warehouse.size_y / 2, warehouse.position_z]}>
<mesh
ref={meshRef}
onClick={(e) => {
e.stopPropagation();
onClick();
}}
onPointerOver={() => setHovered(true)}
onPointerOut={() => setHovered(false)}
>
<boxGeometry args={[warehouse.size_x, warehouse.size_y, warehouse.size_z]} />
<meshStandardMaterial color={warehouse.color} transparent opacity={0.3} wireframe={false} />
</mesh>
{/* 창고 테두리 */}
<lineSegments>
<edgesGeometry args={[new THREE.BoxGeometry(warehouse.size_x, warehouse.size_y, warehouse.size_z)]} />
<lineBasicMaterial color={isSelected ? "#FFD700" : hovered ? "#FFFFFF" : warehouse.color} linewidth={2} />
</lineSegments>
{/* 창고 이름 라벨 */}
<Text position={[0, warehouse.size_y / 2 + 1, 0]} fontSize={1} color="white" anchorX="center" anchorY="middle">
{warehouse.name}
</Text>
{/* 사용률 표시 */}
<Html position={[0, warehouse.size_y / 2 + 2, 0]} center>
<div className="pointer-events-none rounded bg-black/80 px-2 py-1 text-xs text-white">
{usagePercentage.toFixed(0)}%
</div>
</Html>
</group>
);
}
// 자재 3D 박스 컴포넌트
function MaterialBox({
material,
onClick,
isSelected,
}: {
material: MaterialData;
onClick: () => void;
isSelected: boolean;
}) {
const meshRef = useRef<THREE.Mesh>(null);
const [hovered, setHovered] = useState(false);
useFrame(() => {
if (meshRef.current && (isSelected || hovered)) {
meshRef.current.rotation.y += 0.01;
}
});
const statusColor =
{
stocked: material.color,
reserved: "#FFA500",
urgent: "#FF0000",
out_of_stock: "#808080",
}[material.status] || material.color;
return (
<group position={[material.position_x, material.position_y + material.size_y / 2, material.position_z]}>
<mesh
ref={meshRef}
onClick={(e) => {
e.stopPropagation();
onClick();
}}
onPointerOver={() => setHovered(true)}
onPointerOut={() => setHovered(false)}
>
<boxGeometry args={[material.size_x, material.size_y, material.size_z]} />
<meshStandardMaterial color={statusColor} metalness={0.5} roughness={0.2} />
</mesh>
{(hovered || isSelected) && (
<Html position={[0, material.size_y / 2 + 0.5, 0]} center>
<div className="pointer-events-none rounded bg-black/90 px-2 py-1 text-xs text-white shadow-lg">
<div className="font-bold">{material.name}</div>
<div className="text-gray-300">
{material.quantity} {material.unit}
</div>
</div>
</Html>
)}
</group>
);
}
// 3D 씬 컴포넌트
function Scene({
warehouses,
materials,
onSelectWarehouse,
onSelectMaterial,
selectedWarehouse,
selectedMaterial,
}: {
warehouses: WarehouseData[];
materials: MaterialData[];
onSelectWarehouse: (warehouse: WarehouseData | null) => void;
onSelectMaterial: (material: MaterialData | null) => void;
selectedWarehouse: WarehouseData | null;
selectedMaterial: MaterialData | null;
}) {
return (
<>
{/* 조명 */}
<ambientLight intensity={0.5} />
<directionalLight position={[10, 10, 5]} intensity={1} castShadow />
<directionalLight position={[-10, 10, -5]} intensity={0.5} />
{/* 바닥 그리드 */}
<gridHelper args={[100, 50, "#444444", "#222222"]} position={[0, 0, 0]} />
{/* 창고들 */}
{warehouses.map((warehouse) => (
<WarehouseBox
key={warehouse.id}
warehouse={warehouse}
onClick={() => {
if (selectedWarehouse?.id === warehouse.id) {
onSelectWarehouse(null);
} else {
onSelectWarehouse(warehouse);
onSelectMaterial(null);
}
}}
isSelected={selectedWarehouse?.id === warehouse.id}
/>
))}
{/* 자재들 */}
{materials.map((material) => (
<MaterialBox
key={material.id}
material={material}
onClick={() => {
if (selectedMaterial?.id === material.id) {
onSelectMaterial(null);
} else {
onSelectMaterial(material);
}
}}
isSelected={selectedMaterial?.id === material.id}
/>
))}
{/* 카메라 컨트롤 */}
<OrbitControls enableDamping dampingFactor={0.05} minDistance={10} maxDistance={100} />
</>
);
}
export function Warehouse3DWidget({ element }: Warehouse3DWidgetProps) {
const [warehouses, setWarehouses] = useState<WarehouseData[]>([]);
const [materials, setMaterials] = useState<MaterialData[]>([]);
const [loading, setLoading] = useState(true);
const [selectedWarehouse, setSelectedWarehouse] = useState<WarehouseData | null>(null);
const [selectedMaterial, setSelectedMaterial] = useState<MaterialData | null>(null);
const [isFullscreen, setIsFullscreen] = useState(false);
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
try {
setLoading(true);
// API 호출 (백엔드 API 구현 필요)
const response = await fetch("/api/warehouse/data");
if (response.ok) {
const data = await response.json();
setWarehouses(data.warehouses || []);
setMaterials(data.materials || []);
} else {
// 임시 더미 데이터 (개발용)
console.log("API 실패, 더미 데이터 사용");
}
} catch (error) {
console.error("창고 데이터 로드 실패:", error);
} finally {
setLoading(false);
}
};
if (loading) {
return (
<Card className="h-full">
<CardContent className="flex h-full items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
</CardContent>
</Card>
);
}
return (
<Card className={`flex h-full flex-col ${isFullscreen ? "fixed inset-0 z-50" : ""}`}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-lg font-bold">🏭 (3D)</CardTitle>
<div className="flex gap-2">
<Badge variant="outline">
{warehouses.length} | {materials.length}
</Badge>
<button onClick={() => setIsFullscreen(!isFullscreen)} className="text-gray-500 hover:text-gray-700">
<Maximize2 className="h-4 w-4" />
</button>
</div>
</CardHeader>
<CardContent className="flex flex-1 gap-4 p-4">
{/* 3D 뷰 */}
<div className="flex-1 rounded-lg bg-gray-900">
<Canvas camera={{ position: [30, 20, 30], fov: 50 }}>
<Suspense fallback={null}>
<Scene
warehouses={warehouses}
materials={materials}
onSelectWarehouse={setSelectedWarehouse}
onSelectMaterial={setSelectedMaterial}
selectedWarehouse={selectedWarehouse}
selectedMaterial={selectedMaterial}
/>
</Suspense>
</Canvas>
</div>
{/* 정보 패널 */}
<div className="w-80 space-y-4 overflow-y-auto">
{/* 선택된 창고 정보 */}
{selectedWarehouse && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-sm">
<Info className="h-4 w-4" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<div>
<span className="font-semibold">:</span> {selectedWarehouse.name}
</div>
<div>
<span className="font-semibold">ID:</span> {selectedWarehouse.id}
</div>
<div>
<span className="font-semibold">:</span> {selectedWarehouse.current_usage} /{" "}
{selectedWarehouse.capacity}
</div>
<div>
<span className="font-semibold">:</span>{" "}
{((selectedWarehouse.current_usage / selectedWarehouse.capacity) * 100).toFixed(1)}%
</div>
<div>
<span className="font-semibold">:</span>{" "}
<Badge variant={selectedWarehouse.status === "active" ? "default" : "secondary"}>
{selectedWarehouse.status}
</Badge>
</div>
{selectedWarehouse.description && (
<div>
<span className="font-semibold">:</span> {selectedWarehouse.description}
</div>
)}
</CardContent>
</Card>
)}
{/* 선택된 자재 정보 */}
{selectedMaterial && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-sm">
<Info className="h-4 w-4" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<div>
<span className="font-semibold">:</span> {selectedMaterial.name}
</div>
<div>
<span className="font-semibold">:</span> {selectedMaterial.material_code}
</div>
<div>
<span className="font-semibold">:</span> {selectedMaterial.quantity} {selectedMaterial.unit}
</div>
<div>
<span className="font-semibold">:</span>{" "}
{warehouses.find((w) => w.id === selectedMaterial.warehouse_id)?.name}
</div>
<div>
<span className="font-semibold">:</span>{" "}
<Badge
variant={
selectedMaterial.status === "urgent"
? "destructive"
: selectedMaterial.status === "reserved"
? "secondary"
: "default"
}
>
{selectedMaterial.status}
</Badge>
</div>
</CardContent>
</Card>
)}
{/* 창고 목록 */}
{!selectedWarehouse && !selectedMaterial && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm"> </CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{warehouses.map((warehouse) => {
const warehouseMaterials = materials.filter((m) => m.warehouse_id === warehouse.id);
return (
<button
key={warehouse.id}
onClick={() => setSelectedWarehouse(warehouse)}
className="w-full rounded-lg border p-2 text-left transition-colors hover:bg-gray-50"
>
<div className="flex items-center justify-between">
<span className="font-semibold">{warehouse.name}</span>
<Badge variant="outline">{warehouseMaterials.length}</Badge>
</div>
<div className="mt-1 text-xs text-gray-500">
{((warehouse.current_usage / warehouse.capacity) * 100).toFixed(0)}%
</div>
</button>
);
})}
</CardContent>
</Card>
)}
</div>
</CardContent>
</Card>
);
}

View File

@ -1,198 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Plus, Check } from "lucide-react";
import YardLayoutList from "./yard-3d/YardLayoutList";
import YardLayoutCreateModal from "./yard-3d/YardLayoutCreateModal";
import YardEditor from "./yard-3d/YardEditor";
import Yard3DViewer from "./yard-3d/Yard3DViewer";
import { yardLayoutApi } from "@/lib/api/yardLayoutApi";
import type { YardManagementConfig } from "../types";
interface YardLayout {
id: number;
name: string;
description: string;
placement_count: number;
updated_at: string;
}
interface YardManagement3DWidgetProps {
isEditMode?: boolean;
config?: YardManagementConfig;
onConfigChange?: (config: YardManagementConfig) => void;
}
export default function YardManagement3DWidget({
isEditMode = false,
config,
onConfigChange,
}: YardManagement3DWidgetProps) {
const [layouts, setLayouts] = useState<YardLayout[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [editingLayout, setEditingLayout] = useState<YardLayout | null>(null);
// 레이아웃 목록 로드
const loadLayouts = async () => {
try {
setIsLoading(true);
const response = await yardLayoutApi.getAllLayouts();
if (response.success) {
setLayouts(response.data);
}
} catch (error) {
console.error("야드 레이아웃 목록 조회 실패:", error);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
if (isEditMode) {
loadLayouts();
}
}, [isEditMode]);
// 레이아웃 선택 (편집 모드에서만)
const handleSelectLayout = (layout: YardLayout) => {
if (onConfigChange) {
onConfigChange({
layoutId: layout.id,
layoutName: layout.name,
});
}
};
// 새 레이아웃 생성
const handleCreateLayout = async (name: string, description: string) => {
try {
const response = await yardLayoutApi.createLayout({ name, description });
if (response.success) {
await loadLayouts();
setIsCreateModalOpen(false);
setEditingLayout(response.data);
}
} catch (error) {
console.error("야드 레이아웃 생성 실패:", error);
throw error;
}
};
// 편집 완료
const handleEditComplete = () => {
if (editingLayout && onConfigChange) {
onConfigChange({
layoutId: editingLayout.id,
layoutName: editingLayout.name,
});
}
setEditingLayout(null);
loadLayouts();
};
// 편집 모드: 편집 중인 경우 YardEditor 표시
if (isEditMode && editingLayout) {
return (
<div className="h-full w-full">
<YardEditor layout={editingLayout} onBack={handleEditComplete} />
</div>
);
}
// 편집 모드: 레이아웃 선택 UI
if (isEditMode) {
return (
<div className="flex h-full w-full flex-col bg-white">
<div className="flex items-center justify-between border-b p-4">
<div>
<h3 className="text-sm font-semibold text-gray-700"> </h3>
<p className="mt-1 text-xs text-gray-500">
{config?.layoutName ? `선택됨: ${config.layoutName}` : "표시할 야드 레이아웃을 선택하세요"}
</p>
</div>
<Button onClick={() => setIsCreateModalOpen(true)} size="sm">
<Plus className="mr-1 h-4 w-4" />
</Button>
</div>
<div className="flex-1 overflow-auto p-4">
{isLoading ? (
<div className="flex h-full items-center justify-center">
<div className="text-sm text-gray-500"> ...</div>
</div>
) : layouts.length === 0 ? (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<div className="mb-2 text-4xl">🏗</div>
<div className="text-sm text-gray-600"> </div>
<div className="mt-1 text-xs text-gray-400"> </div>
</div>
</div>
) : (
<div className="grid gap-3">
{layouts.map((layout) => (
<div
key={layout.id}
className={`rounded-lg border p-3 transition-all ${
config?.layoutId === layout.id ? "border-blue-500 bg-blue-50" : "border-gray-200 bg-white"
}`}
>
<div className="flex items-start justify-between gap-3">
<button onClick={() => handleSelectLayout(layout)} className="flex-1 text-left hover:opacity-80">
<div className="flex items-center gap-2">
<span className="font-medium text-gray-900">{layout.name}</span>
{config?.layoutId === layout.id && <Check className="h-4 w-4 text-blue-600" />}
</div>
{layout.description && <p className="mt-1 text-xs text-gray-500">{layout.description}</p>}
<div className="mt-2 text-xs text-gray-400"> : {layout.placement_count}</div>
</button>
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
setEditingLayout(layout);
}}
>
</Button>
</div>
</div>
))}
</div>
)}
</div>
{/* 생성 모달 */}
<YardLayoutCreateModal
isOpen={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)}
onCreate={handleCreateLayout}
/>
</div>
);
}
// 뷰 모드: 선택된 레이아웃의 3D 뷰어 표시
if (!config?.layoutId) {
return (
<div className="flex h-full w-full items-center justify-center bg-gray-50">
<div className="text-center">
<div className="mb-2 text-4xl">🏗</div>
<div className="text-sm font-medium text-gray-600"> </div>
<div className="mt-1 text-xs text-gray-400"> </div>
</div>
</div>
);
}
// 선택된 레이아웃의 3D 뷰어 표시
return (
<div className="h-full w-full">
<Yard3DViewer layoutId={config.layoutId} />
</div>
);
}

View File

@ -1,247 +0,0 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Loader2 } from "lucide-react";
interface TempMaterial {
id: number;
material_code: string;
material_name: string;
category: string;
unit: string;
default_color: string;
description: string;
}
interface MaterialAddModalProps {
isOpen: boolean;
material: TempMaterial | null;
onClose: () => void;
onAdd: (placementData: any) => Promise<void>;
}
export default function MaterialAddModal({ isOpen, material, onClose, onAdd }: MaterialAddModalProps) {
const [quantity, setQuantity] = useState("1");
const [positionX, setPositionX] = useState("0");
const [positionY, setPositionY] = useState("0");
const [positionZ, setPositionZ] = useState("0");
const [sizeX, setSizeX] = useState("5");
const [sizeY, setSizeY] = useState("5");
const [sizeZ, setSizeZ] = useState("5");
const [color, setColor] = useState("");
const [isAdding, setIsAdding] = useState(false);
// 모달이 열릴 때 기본값 설정
const handleOpen = (open: boolean) => {
if (open && material) {
setColor(material.default_color);
setQuantity("1");
setPositionX("0");
setPositionY("0");
setPositionZ("0");
setSizeX("5");
setSizeY("5");
setSizeZ("5");
}
};
// 자재 추가
const handleAdd = async () => {
if (!material) return;
setIsAdding(true);
try {
await onAdd({
external_material_id: `TEMP-${Date.now()}`,
material_code: material.material_code,
material_name: material.material_name,
quantity: parseInt(quantity) || 1,
unit: material.unit,
position_x: parseFloat(positionX) || 0,
position_y: parseFloat(positionY) || 0,
position_z: parseFloat(positionZ) || 0,
size_x: parseFloat(sizeX) || 5,
size_y: parseFloat(sizeY) || 5,
size_z: parseFloat(sizeZ) || 5,
color: color || material.default_color,
});
onClose();
} catch (error) {
console.error("자재 추가 실패:", error);
} finally {
setIsAdding(false);
}
};
if (!material) return null;
return (
<Dialog
open={isOpen}
onOpenChange={(open) => {
handleOpen(open);
if (!open) onClose();
}}
>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle> </DialogTitle>
</DialogHeader>
<div className="space-y-4">
{/* 자재 정보 */}
<div className="rounded-lg bg-gray-50 p-4">
<div className="mb-2 text-sm font-medium text-gray-600"> </div>
<div className="flex items-center gap-4">
<div className="h-10 w-10 rounded border" style={{ backgroundColor: material.default_color }} />
<div>
<div className="font-medium">{material.material_name}</div>
<div className="text-sm text-gray-600">{material.material_code}</div>
</div>
</div>
</div>
{/* 수량 */}
<div className="space-y-2">
<Label htmlFor="quantity"></Label>
<div className="flex items-center gap-2">
<Input
id="quantity"
type="number"
value={quantity}
onChange={(e) => setQuantity(e.target.value)}
min="1"
className="flex-1"
/>
<span className="text-sm text-gray-600">{material.unit}</span>
</div>
</div>
{/* 3D 위치 */}
<div className="space-y-2">
<Label>3D </Label>
<div className="grid grid-cols-3 gap-2">
<div>
<Label htmlFor="posX" className="text-xs text-gray-600">
X ()
</Label>
<Input
id="posX"
type="number"
value={positionX}
onChange={(e) => setPositionX(e.target.value)}
step="0.5"
/>
</div>
<div>
<Label htmlFor="posY" className="text-xs text-gray-600">
Y ()
</Label>
<Input
id="posY"
type="number"
value={positionY}
onChange={(e) => setPositionY(e.target.value)}
step="0.5"
/>
</div>
<div>
<Label htmlFor="posZ" className="text-xs text-gray-600">
Z ()
</Label>
<Input
id="posZ"
type="number"
value={positionZ}
onChange={(e) => setPositionZ(e.target.value)}
step="0.5"
/>
</div>
</div>
</div>
{/* 3D 크기 */}
<div className="space-y-2">
<Label>3D </Label>
<div className="grid grid-cols-3 gap-2">
<div>
<Label htmlFor="sizeX" className="text-xs text-gray-600">
</Label>
<Input
id="sizeX"
type="number"
value={sizeX}
onChange={(e) => setSizeX(e.target.value)}
min="1"
step="0.5"
/>
</div>
<div>
<Label htmlFor="sizeY" className="text-xs text-gray-600">
</Label>
<Input
id="sizeY"
type="number"
value={sizeY}
onChange={(e) => setSizeY(e.target.value)}
min="1"
step="0.5"
/>
</div>
<div>
<Label htmlFor="sizeZ" className="text-xs text-gray-600">
</Label>
<Input
id="sizeZ"
type="number"
value={sizeZ}
onChange={(e) => setSizeZ(e.target.value)}
min="1"
step="0.5"
/>
</div>
</div>
</div>
{/* 색상 */}
<div className="space-y-2">
<Label htmlFor="color"></Label>
<div className="flex items-center gap-2">
<input
id="color"
type="color"
value={color}
onChange={(e) => setColor(e.target.value)}
className="h-10 w-20 cursor-pointer rounded border"
/>
<Input value={color} onChange={(e) => setColor(e.target.value)} className="flex-1" />
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={isAdding}>
</Button>
<Button onClick={handleAdd} disabled={isAdding}>
{isAdding ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
"배치"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -1,277 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Trash2 } from "lucide-react";
interface YardPlacement {
id: number;
external_material_id: string;
material_code: string;
material_name: string;
quantity: number;
unit: string;
position_x: number;
position_y: number;
position_z: number;
size_x: number;
size_y: number;
size_z: number;
color: string;
memo?: string;
}
interface MaterialEditPanelProps {
placement: YardPlacement | null;
onClose: () => void;
onUpdate: (id: number, updates: Partial<YardPlacement>) => void;
onRemove: (id: number) => void;
}
export default function MaterialEditPanel({ placement, onClose, onUpdate, onRemove }: MaterialEditPanelProps) {
const [editData, setEditData] = useState<Partial<YardPlacement>>({});
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
// placement 변경 시 editData 초기화
useEffect(() => {
if (placement) {
setEditData({
position_x: placement.position_x,
position_y: placement.position_y,
position_z: placement.position_z,
size_x: placement.size_x,
size_y: placement.size_y,
size_z: placement.size_z,
color: placement.color,
memo: placement.memo,
});
}
}, [placement]);
if (!placement) return null;
// 변경사항 적용
const handleApply = () => {
onUpdate(placement.id, editData);
};
// 배치 해제
const handleRemove = () => {
onRemove(placement.id);
setIsDeleteDialogOpen(false);
};
return (
<div className="w-80 border-l bg-white p-4">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-semibold"> </h3>
<Button variant="ghost" size="sm" onClick={onClose}>
</Button>
</div>
<div className="space-y-4">
{/* 읽기 전용 정보 */}
<div className="space-y-3 rounded-lg bg-gray-50 p-3">
<div className="text-xs font-medium text-gray-500"> ( )</div>
<div>
<div className="text-xs text-gray-600"> </div>
<div className="mt-1 text-sm font-medium">{placement.material_code}</div>
</div>
<div>
<div className="text-xs text-gray-600"> </div>
<div className="mt-1 text-sm font-medium">{placement.material_name}</div>
</div>
<div>
<div className="text-xs text-gray-600"></div>
<div className="mt-1 text-sm font-medium">
{placement.quantity} {placement.unit}
</div>
</div>
</div>
{/* 배치 정보 (편집 가능) */}
<div className="space-y-3">
<div className="text-xs font-medium text-gray-500"> ( )</div>
{/* 3D 위치 */}
<div>
<Label className="text-xs"></Label>
<div className="grid grid-cols-3 gap-2">
<div>
<Label htmlFor="edit-posX" className="text-xs text-gray-600">
X
</Label>
<Input
id="edit-posX"
type="number"
value={editData.position_x ?? placement.position_x}
onChange={(e) => setEditData({ ...editData, position_x: parseFloat(e.target.value) || 0 })}
step="0.5"
className="h-8 text-xs"
/>
</div>
<div>
<Label htmlFor="edit-posY" className="text-xs text-gray-600">
Y
</Label>
<Input
id="edit-posY"
type="number"
value={editData.position_y ?? placement.position_y}
onChange={(e) => setEditData({ ...editData, position_y: parseFloat(e.target.value) || 0 })}
step="0.5"
className="h-8 text-xs"
/>
</div>
<div>
<Label htmlFor="edit-posZ" className="text-xs text-gray-600">
Z
</Label>
<Input
id="edit-posZ"
type="number"
value={editData.position_z ?? placement.position_z}
onChange={(e) => setEditData({ ...editData, position_z: parseFloat(e.target.value) || 0 })}
step="0.5"
className="h-8 text-xs"
/>
</div>
</div>
</div>
{/* 3D 크기 */}
<div>
<Label className="text-xs"></Label>
<div className="grid grid-cols-3 gap-2">
<div>
<Label htmlFor="edit-sizeX" className="text-xs text-gray-600">
</Label>
<Input
id="edit-sizeX"
type="number"
value={editData.size_x ?? placement.size_x}
onChange={(e) => setEditData({ ...editData, size_x: parseFloat(e.target.value) || 1 })}
min="1"
step="0.5"
className="h-8 text-xs"
/>
</div>
<div>
<Label htmlFor="edit-sizeY" className="text-xs text-gray-600">
</Label>
<Input
id="edit-sizeY"
type="number"
value={editData.size_y ?? placement.size_y}
onChange={(e) => setEditData({ ...editData, size_y: parseFloat(e.target.value) || 1 })}
min="1"
step="0.5"
className="h-8 text-xs"
/>
</div>
<div>
<Label htmlFor="edit-sizeZ" className="text-xs text-gray-600">
</Label>
<Input
id="edit-sizeZ"
type="number"
value={editData.size_z ?? placement.size_z}
onChange={(e) => setEditData({ ...editData, size_z: parseFloat(e.target.value) || 1 })}
min="1"
step="0.5"
className="h-8 text-xs"
/>
</div>
</div>
</div>
{/* 색상 */}
<div>
<Label htmlFor="edit-color" className="text-xs">
</Label>
<div className="mt-1 flex items-center gap-2">
<input
id="edit-color"
type="color"
value={editData.color ?? placement.color}
onChange={(e) => setEditData({ ...editData, color: e.target.value })}
className="h-8 w-16 cursor-pointer rounded border"
/>
<Input
value={editData.color ?? placement.color}
onChange={(e) => setEditData({ ...editData, color: e.target.value })}
className="h-8 flex-1 text-xs"
/>
</div>
</div>
{/* 메모 */}
<div>
<Label htmlFor="edit-memo" className="text-xs">
</Label>
<Textarea
id="edit-memo"
value={editData.memo ?? placement.memo ?? ""}
onChange={(e) => setEditData({ ...editData, memo: e.target.value })}
placeholder="메모를 입력하세요"
rows={3}
className="text-xs"
/>
</div>
{/* 적용 버튼 */}
<Button onClick={handleApply} className="w-full" size="sm">
</Button>
</div>
{/* 배치 해제 */}
<div className="border-t pt-4">
<Button variant="destructive" onClick={() => setIsDeleteDialogOpen(true)} className="w-full" size="sm">
<Trash2 className="mr-2 h-4 w-4" />
</Button>
</div>
</div>
{/* 삭제 확인 모달 */}
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
?
<br />
&quot;{placement.material_name}&quot; ({placement.quantity} {placement.unit})
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleRemove} className="bg-red-600 hover:bg-red-700">
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@ -1,192 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Search, Loader2 } from "lucide-react";
import { materialApi } from "@/lib/api/yardLayoutApi";
interface TempMaterial {
id: number;
material_code: string;
material_name: string;
category: string;
unit: string;
default_color: string;
description: string;
}
interface MaterialLibraryProps {
isOpen: boolean;
onClose: () => void;
onSelect: (material: TempMaterial) => void;
}
export default function MaterialLibrary({ isOpen, onClose, onSelect }: MaterialLibraryProps) {
const [materials, setMaterials] = useState<TempMaterial[]>([]);
const [categories, setCategories] = useState<string[]>([]);
const [searchText, setSearchText] = useState("");
const [selectedCategory, setSelectedCategory] = useState<string>("");
const [isLoading, setIsLoading] = useState(false);
const [selectedMaterial, setSelectedMaterial] = useState<TempMaterial | null>(null);
// 자재 목록 로드
const loadMaterials = async () => {
try {
setIsLoading(true);
const [materialsResponse, categoriesResponse] = await Promise.all([
materialApi.getTempMaterials({
search: searchText || undefined,
category: selectedCategory || undefined,
page: 1,
limit: 50,
}),
materialApi.getCategories(),
]);
if (materialsResponse.success) {
setMaterials(materialsResponse.data);
}
if (categoriesResponse.success) {
setCategories(categoriesResponse.data);
}
} catch (error) {
console.error("자재 목록 조회 실패:", error);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
if (isOpen) {
loadMaterials();
}
}, [isOpen, searchText, selectedCategory]);
// 자재 선택 및 추가
const handleSelectMaterial = () => {
if (selectedMaterial) {
onSelect(selectedMaterial);
setSelectedMaterial(null);
onClose();
}
};
// 모달 닫기
const handleClose = () => {
setSelectedMaterial(null);
setSearchText("");
setSelectedCategory("");
onClose();
};
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-4xl">
<DialogHeader>
<DialogTitle> </DialogTitle>
</DialogHeader>
<div className="space-y-4">
{/* 검색 및 필터 */}
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
<Input
placeholder="자재 코드 또는 이름 검색..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="pl-9"
/>
</div>
<select
value={selectedCategory}
onChange={(e) => setSelectedCategory(e.target.value)}
className="rounded-md border border-gray-300 px-3 py-2 text-sm"
>
<option value=""> </option>
{categories.map((category) => (
<option key={category} value={category}>
{category}
</option>
))}
</select>
</div>
{/* 자재 목록 */}
{isLoading ? (
<div className="flex h-64 items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
</div>
) : materials.length === 0 ? (
<div className="flex h-64 items-center justify-center text-gray-500">
{searchText || selectedCategory ? "검색 결과가 없습니다" : "등록된 자재가 없습니다"}
</div>
) : (
<div className="max-h-96 overflow-auto rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[50px]"></TableHead>
<TableHead> </TableHead>
<TableHead> </TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{materials.map((material) => (
<TableRow
key={material.id}
className={`cursor-pointer ${
selectedMaterial?.id === material.id ? "bg-blue-50" : "hover:bg-gray-50"
}`}
onClick={() => setSelectedMaterial(material)}
>
<TableCell>
<div className="h-6 w-6 rounded border" style={{ backgroundColor: material.default_color }} />
</TableCell>
<TableCell className="font-medium">{material.material_code}</TableCell>
<TableCell>{material.material_name}</TableCell>
<TableCell>{material.category}</TableCell>
<TableCell>{material.unit}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{/* 선택된 자재 정보 */}
{selectedMaterial && (
<div className="rounded-lg bg-blue-50 p-4">
<div className="mb-2 text-sm font-medium text-blue-900"> </div>
<div className="flex items-center gap-4">
<div className="h-10 w-10 rounded border" style={{ backgroundColor: selectedMaterial.default_color }} />
<div className="flex-1">
<div className="font-medium">{selectedMaterial.material_name}</div>
<div className="text-sm text-gray-600">{selectedMaterial.material_code}</div>
</div>
</div>
{selectedMaterial.description && (
<div className="mt-2 text-sm text-gray-600">{selectedMaterial.description}</div>
)}
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={handleClose}>
</Button>
<Button onClick={handleSelectMaterial} disabled={!selectedMaterial}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -1,299 +0,0 @@
"use client";
import { Canvas, useThree } from "@react-three/fiber";
import { OrbitControls, Grid, Box } from "@react-three/drei";
import { Suspense, useRef, useState, useEffect } from "react";
import * as THREE from "three";
interface YardPlacement {
id: number;
external_material_id: string;
material_code: string;
material_name: string;
quantity: number;
unit: string;
position_x: number;
position_y: number;
position_z: number;
size_x: number;
size_y: number;
size_z: number;
color: string;
}
interface Yard3DCanvasProps {
placements: YardPlacement[];
selectedPlacementId: number | null;
onPlacementClick: (placement: YardPlacement) => void;
onPlacementDrag?: (id: number, position: { x: number; y: number; z: number }) => void;
}
// 자재 박스 컴포넌트 (드래그 가능)
function MaterialBox({
placement,
isSelected,
onClick,
onDrag,
onDragStart,
onDragEnd,
}: {
placement: YardPlacement;
isSelected: boolean;
onClick: () => void;
onDrag?: (position: { x: number; y: number; z: number }) => void;
onDragStart?: () => void;
onDragEnd?: () => void;
}) {
const meshRef = useRef<THREE.Mesh>(null);
const [isDragging, setIsDragging] = useState(false);
const dragStartPos = useRef<{ x: number; y: number; z: number }>({ x: 0, y: 0, z: 0 });
const mouseStartPos = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
const { camera, gl } = useThree();
// 드래그 중이 아닐 때 위치 업데이트
useEffect(() => {
if (!isDragging && meshRef.current) {
meshRef.current.position.set(placement.position_x, placement.position_y, placement.position_z);
}
}, [placement.position_x, placement.position_y, placement.position_z, isDragging]);
// 전역 이벤트 리스너 등록
useEffect(() => {
const handleGlobalMouseMove = (e: MouseEvent) => {
if (isDragging && onDrag && meshRef.current) {
e.preventDefault();
e.stopPropagation();
// 마우스 이동 거리 계산 (픽셀)
const deltaX = e.clientX - mouseStartPos.current.x;
const deltaY = e.clientY - mouseStartPos.current.y;
// 카메라 거리를 고려한 스케일 팩터
const distance = camera.position.distanceTo(meshRef.current.position);
const scaleFactor = distance / 500; // 조정 가능한 값
// 카메라 방향 벡터
const cameraDirection = new THREE.Vector3();
camera.getWorldDirection(cameraDirection);
// 카메라의 우측 벡터 (X축 이동용)
const right = new THREE.Vector3();
right.crossVectors(camera.up, cameraDirection).normalize();
// 실제 3D 공간에서의 이동량 계산
const moveRight = right.multiplyScalar(-deltaX * scaleFactor);
const moveForward = new THREE.Vector3(-cameraDirection.x, 0, -cameraDirection.z)
.normalize()
.multiplyScalar(deltaY * scaleFactor);
// 최종 위치 계산
const finalX = dragStartPos.current.x + moveRight.x + moveForward.x;
const finalZ = dragStartPos.current.z + moveRight.z + moveForward.z;
// NaN 검증
if (isNaN(finalX) || isNaN(finalZ)) {
return;
}
// 즉시 mesh 위치 업데이트 (부드러운 드래그)
meshRef.current.position.set(finalX, dragStartPos.current.y, finalZ);
// 상태 업데이트 (저장용)
onDrag({
x: finalX,
y: dragStartPos.current.y,
z: finalZ,
});
}
};
const handleGlobalMouseUp = () => {
if (isDragging) {
setIsDragging(false);
gl.domElement.style.cursor = isSelected ? "grab" : "pointer";
if (onDragEnd) {
onDragEnd();
}
}
};
if (isDragging) {
window.addEventListener("mousemove", handleGlobalMouseMove);
window.addEventListener("mouseup", handleGlobalMouseUp);
return () => {
window.removeEventListener("mousemove", handleGlobalMouseMove);
window.removeEventListener("mouseup", handleGlobalMouseUp);
};
}
}, [isDragging, onDrag, onDragEnd, camera, isSelected, gl.domElement]);
const handlePointerDown = (e: any) => {
e.stopPropagation();
// 뷰어 모드(onDrag 없음)에서는 클릭만 처리
if (!onDrag) {
return;
}
// 편집 모드에서 선택되었고 드래그 가능한 경우
if (isSelected && meshRef.current) {
// 드래그 시작 시점의 자재 위치 저장 (숫자로 변환)
dragStartPos.current = {
x: Number(placement.position_x),
y: Number(placement.position_y),
z: Number(placement.position_z),
};
// 마우스 시작 위치 저장
mouseStartPos.current = {
x: e.clientX,
y: e.clientY,
};
setIsDragging(true);
gl.domElement.style.cursor = "grabbing";
if (onDragStart) {
onDragStart();
}
}
};
return (
<Box
ref={meshRef}
position={[placement.position_x, placement.position_y, placement.position_z]}
args={[placement.size_x, placement.size_y, placement.size_z]}
onClick={(e) => {
e.stopPropagation();
e.nativeEvent?.stopPropagation();
e.nativeEvent?.stopImmediatePropagation();
console.log("3D Box clicked:", placement.material_name);
onClick();
}}
onPointerDown={handlePointerDown}
onPointerOver={() => {
// 뷰어 모드(onDrag 없음)에서는 기본 커서, 편집 모드에서는 grab 커서
if (onDrag) {
gl.domElement.style.cursor = isSelected ? "grab" : "pointer";
} else {
gl.domElement.style.cursor = "pointer";
}
}}
onPointerOut={() => {
if (!isDragging) {
gl.domElement.style.cursor = "default";
}
}}
>
<meshStandardMaterial
color={placement.color}
opacity={isSelected ? 1 : 0.8}
transparent
emissive={isSelected ? "#ffffff" : "#000000"}
emissiveIntensity={isSelected ? 0.2 : 0}
/>
</Box>
);
}
// 3D 씬 컴포넌트
function Scene({ placements, selectedPlacementId, onPlacementClick, onPlacementDrag }: Yard3DCanvasProps) {
const [isDraggingAny, setIsDraggingAny] = useState(false);
const orbitControlsRef = useRef<any>(null);
return (
<>
{/* 조명 */}
<ambientLight intensity={0.5} />
<directionalLight position={[10, 10, 5]} intensity={1} />
<directionalLight position={[-10, -10, -5]} intensity={0.3} />
{/* 바닥 그리드 */}
<Grid
args={[100, 100]}
cellSize={5}
cellThickness={0.5}
cellColor="#6b7280"
sectionSize={10}
sectionThickness={1}
sectionColor="#374151"
fadeDistance={200}
fadeStrength={1}
followCamera={false}
infiniteGrid={true}
/>
{/* 자재 박스들 */}
{placements.map((placement) => (
<MaterialBox
key={placement.id}
placement={placement}
isSelected={selectedPlacementId === placement.id}
onClick={() => onPlacementClick(placement)}
onDrag={onPlacementDrag ? (position) => onPlacementDrag(placement.id, position) : undefined}
onDragStart={() => {
setIsDraggingAny(true);
if (orbitControlsRef.current) {
orbitControlsRef.current.enabled = false;
}
}}
onDragEnd={() => {
setIsDraggingAny(false);
if (orbitControlsRef.current) {
orbitControlsRef.current.enabled = true;
}
}}
/>
))}
{/* 카메라 컨트롤 */}
<OrbitControls
ref={orbitControlsRef}
enablePan={true}
enableZoom={true}
enableRotate={true}
minDistance={10}
maxDistance={200}
maxPolarAngle={Math.PI / 2}
enabled={!isDraggingAny}
/>
</>
);
}
export default function Yard3DCanvas({
placements,
selectedPlacementId,
onPlacementClick,
onPlacementDrag,
}: Yard3DCanvasProps) {
const handleCanvasClick = (e: any) => {
// Canvas의 빈 공간을 클릭했을 때만 선택 해제
// e.target이 canvas 엘리먼트인 경우
if (e.target.tagName === "CANVAS") {
onPlacementClick(null as any);
}
};
return (
<div className="h-full w-full bg-gray-900" onClick={handleCanvasClick}>
<Canvas
camera={{
position: [50, 30, 50],
fov: 50,
}}
shadows
>
<Suspense fallback={null}>
<Scene
placements={placements}
selectedPlacementId={selectedPlacementId}
onPlacementClick={onPlacementClick}
onPlacementDrag={onPlacementDrag}
/>
</Suspense>
</Canvas>
</div>
);
}

View File

@ -1,161 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import Yard3DCanvas from "./Yard3DCanvas";
import { yardLayoutApi } from "@/lib/api/yardLayoutApi";
import { Loader2 } from "lucide-react";
interface YardPlacement {
id: number;
yard_layout_id: number;
external_material_id: string;
material_code: string;
material_name: string;
quantity: number;
unit: string;
position_x: number;
position_y: number;
position_z: number;
size_x: number;
size_y: number;
size_z: number;
color: string;
status?: string;
memo?: string;
}
interface Yard3DViewerProps {
layoutId: number;
}
export default function Yard3DViewer({ layoutId }: Yard3DViewerProps) {
const [placements, setPlacements] = useState<YardPlacement[]>([]);
const [selectedPlacement, setSelectedPlacement] = useState<YardPlacement | null>(null);
const [layoutName, setLayoutName] = useState<string>("");
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// 선택 변경 로그
const handlePlacementClick = (placement: YardPlacement | null) => {
console.log("Yard3DViewer - Placement clicked:", placement?.material_name);
setSelectedPlacement(placement);
};
// 선택 상태 변경 감지
useEffect(() => {
console.log("selectedPlacement changed:", selectedPlacement?.material_name);
}, [selectedPlacement]);
// 야드 레이아웃 및 배치 데이터 로드
useEffect(() => {
const loadData = async () => {
try {
setIsLoading(true);
setError(null);
// 야드 레이아웃 정보 조회
const layoutResponse = await yardLayoutApi.getLayoutById(layoutId);
if (layoutResponse.success) {
setLayoutName(layoutResponse.data.name);
}
// 배치 데이터 조회
const placementsResponse = await yardLayoutApi.getPlacementsByLayoutId(layoutId);
if (placementsResponse.success) {
setPlacements(placementsResponse.data);
} else {
setError("배치 데이터를 불러올 수 없습니다.");
}
} catch (err) {
console.error("데이터 로드 실패:", err);
setError("데이터를 불러오는 중 오류가 발생했습니다.");
} finally {
setIsLoading(false);
}
};
loadData();
}, [layoutId]);
if (isLoading) {
return (
<div className="flex h-full w-full items-center justify-center bg-gray-50">
<div className="text-center">
<Loader2 className="mx-auto h-8 w-8 animate-spin text-blue-600" />
<div className="mt-2 text-sm text-gray-600">3D ...</div>
</div>
</div>
);
}
if (error) {
return (
<div className="flex h-full w-full items-center justify-center bg-gray-50">
<div className="text-center">
<div className="mb-2 text-4xl"></div>
<div className="text-sm font-medium text-gray-600">{error}</div>
</div>
</div>
);
}
if (placements.length === 0) {
return (
<div className="flex h-full w-full items-center justify-center bg-gray-50">
<div className="text-center">
<div className="mb-2 text-4xl">📦</div>
<div className="text-sm font-medium text-gray-600"> </div>
</div>
</div>
);
}
return (
<div className="relative h-full w-full">
{/* 3D 캔버스 */}
<Yard3DCanvas
placements={placements}
selectedPlacementId={selectedPlacement?.id || null}
onPlacementClick={handlePlacementClick}
/>
{/* 야드 이름 (좌측 상단) */}
{layoutName && (
<div className="absolute top-4 left-4 z-50 rounded-lg border border-gray-300 bg-white px-4 py-2 shadow-lg">
<h2 className="text-base font-bold text-gray-900">{layoutName}</h2>
</div>
)}
{/* 선택된 자재 정보 패널 (우측 상단) */}
{selectedPlacement && (
<div className="absolute top-4 right-4 z-50 w-64 rounded-lg border border-gray-300 bg-white p-4 shadow-xl">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-800"> </h3>
<button
onClick={() => {
setSelectedPlacement(null);
}}
className="rounded-full p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
>
</button>
</div>
<div className="space-y-2">
<div>
<label className="text-xs font-medium text-gray-500"></label>
<div className="mt-1 text-sm font-semibold text-gray-900">{selectedPlacement.material_name}</div>
</div>
<div>
<label className="text-xs font-medium text-gray-500"></label>
<div className="mt-1 text-sm font-semibold text-gray-900">
{selectedPlacement.quantity} {selectedPlacement.unit}
</div>
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -1,461 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { ArrowLeft, Save, Loader2, X } from "lucide-react";
import { yardLayoutApi, materialApi } from "@/lib/api/yardLayoutApi";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import dynamic from "next/dynamic";
const Yard3DCanvas = dynamic(() => import("./Yard3DCanvas"), {
ssr: false,
loading: () => (
<div className="flex h-full items-center justify-center bg-gray-900">
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
</div>
),
});
interface TempMaterial {
id: number;
material_code: string;
material_name: string;
category: string;
unit: string;
default_color: string;
description: string;
}
interface YardLayout {
id: number;
name: string;
description: string;
placement_count?: number;
updated_at: string;
}
interface YardPlacement {
id: number;
yard_layout_id: number;
external_material_id: string;
material_code: string;
material_name: string;
quantity: number;
unit: string;
position_x: number;
position_y: number;
position_z: number;
size_x: number;
size_y: number;
size_z: number;
color: string;
memo?: string;
}
interface YardEditorProps {
layout: YardLayout;
onBack: () => void;
}
export default function YardEditor({ layout, onBack }: YardEditorProps) {
const [placements, setPlacements] = useState<YardPlacement[]>([]);
const [materials, setMaterials] = useState<TempMaterial[]>([]);
const [selectedPlacement, setSelectedPlacement] = useState<YardPlacement | null>(null);
const [selectedMaterial, setSelectedMaterial] = useState<TempMaterial | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
// 배치 목록 & 자재 목록 로드
useEffect(() => {
const loadData = async () => {
try {
setIsLoading(true);
const [placementsRes, materialsRes] = await Promise.all([
yardLayoutApi.getPlacementsByLayoutId(layout.id),
materialApi.getTempMaterials({ limit: 100 }),
]);
if (placementsRes.success) {
setPlacements(placementsRes.data);
}
if (materialsRes.success) {
setMaterials(materialsRes.data);
}
} catch (error) {
console.error("데이터 로드 실패:", error);
} finally {
setIsLoading(false);
}
};
loadData();
}, [layout.id]);
// 자재 클릭 → 배치 추가
const handleMaterialClick = async (material: TempMaterial) => {
// 이미 배치되었는지 확인
const alreadyPlaced = placements.find((p) => p.material_code === material.material_code);
if (alreadyPlaced) {
alert("이미 배치된 자재입니다.");
return;
}
setSelectedMaterial(material);
// 기본 위치에 배치
const placementData = {
external_material_id: `TEMP-${material.id}`,
material_code: material.material_code,
material_name: material.material_name,
quantity: 1,
unit: material.unit,
position_x: 0,
position_y: 0,
position_z: 0,
size_x: 5,
size_y: 5,
size_z: 5,
color: material.default_color,
};
try {
const response = await yardLayoutApi.addMaterialPlacement(layout.id, placementData);
if (response.success) {
setPlacements((prev) => [...prev, response.data]);
setSelectedPlacement(response.data);
setSelectedMaterial(null);
}
} catch (error: any) {
console.error("자재 배치 실패:", error);
alert("자재 배치에 실패했습니다.");
}
};
// 자재 드래그 (3D 캔버스에서)
const handlePlacementDrag = (id: number, position: { x: number; y: number; z: number }) => {
const updatedPosition = {
position_x: Math.round(position.x * 2) / 2,
position_y: position.y,
position_z: Math.round(position.z * 2) / 2,
};
setPlacements((prev) =>
prev.map((p) =>
p.id === id
? {
...p,
...updatedPosition,
}
: p,
),
);
// 선택된 자재도 업데이트
if (selectedPlacement?.id === id) {
setSelectedPlacement((prev) =>
prev
? {
...prev,
...updatedPosition,
}
: null,
);
}
};
// 자재 배치 해제
const handlePlacementRemove = async (id: number) => {
try {
const response = await yardLayoutApi.removePlacement(id);
if (response.success) {
setPlacements((prev) => prev.filter((p) => p.id !== id));
setSelectedPlacement(null);
}
} catch (error) {
console.error("배치 해제 실패:", error);
alert("배치 해제에 실패했습니다.");
}
};
// 위치/크기/색상 업데이트
const handlePlacementUpdate = (id: number, updates: Partial<YardPlacement>) => {
setPlacements((prev) => prev.map((p) => (p.id === id ? { ...p, ...updates } : p)));
};
// 저장
const handleSave = async () => {
setIsSaving(true);
try {
const response = await yardLayoutApi.batchUpdatePlacements(
layout.id,
placements.map((p) => ({
id: p.id,
position_x: p.position_x,
position_y: p.position_y,
position_z: p.position_z,
size_x: p.size_x,
size_y: p.size_y,
size_z: p.size_z,
color: p.color,
})),
);
if (response.success) {
alert("저장되었습니다");
}
} catch (error) {
console.error("저장 실패:", error);
alert("저장에 실패했습니다");
} finally {
setIsSaving(false);
}
};
// 필터링된 자재 목록
const filteredMaterials = materials.filter(
(m) =>
m.material_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
m.material_code.toLowerCase().includes(searchTerm.toLowerCase()),
);
return (
<div className="flex h-full flex-col bg-white">
{/* 상단 툴바 */}
<div className="flex items-center justify-between border-b p-4">
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" onClick={onBack}>
<ArrowLeft className="mr-2 h-4 w-4" />
</Button>
<div>
<h2 className="text-lg font-semibold">{layout.name}</h2>
{layout.description && <p className="text-sm text-gray-500">{layout.description}</p>}
</div>
</div>
<Button size="sm" onClick={handleSave} disabled={isSaving}>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
</>
)}
</Button>
</div>
{/* 메인 컨텐츠 영역 */}
<div className="flex flex-1 overflow-hidden">
{/* 좌측: 3D 캔버스 */}
<div className="flex-1">
{isLoading ? (
<div className="flex h-full items-center justify-center bg-gray-50">
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
</div>
) : (
<Yard3DCanvas
placements={placements}
selectedPlacementId={selectedPlacement?.id || null}
onPlacementClick={setSelectedPlacement}
onPlacementDrag={handlePlacementDrag}
/>
)}
</div>
{/* 우측: 자재 목록 또는 편집 패널 */}
<div className="w-80 border-l bg-white">
{selectedPlacement ? (
// 선택된 자재 편집 패널
<div className="flex h-full flex-col">
<div className="flex items-center justify-between border-b p-4">
<h3 className="text-sm font-semibold"> </h3>
<Button variant="ghost" size="sm" onClick={() => setSelectedPlacement(null)}>
<X className="h-4 w-4" />
</Button>
</div>
<div className="flex-1 overflow-auto p-4">
<div className="space-y-4">
{/* 읽기 전용 정보 */}
<div>
<Label className="text-xs text-gray-500"> </Label>
<div className="mt-1 text-sm font-medium">{selectedPlacement.material_code}</div>
</div>
<div>
<Label className="text-xs text-gray-500"></Label>
<div className="mt-1 text-sm font-medium">{selectedPlacement.material_name}</div>
</div>
<div>
<Label className="text-xs text-gray-500"> ( )</Label>
<div className="mt-1 text-sm">
{selectedPlacement.quantity} {selectedPlacement.unit}
</div>
</div>
{/* 편집 가능 정보 */}
<div className="space-y-3 border-t pt-4">
<Label className="text-sm font-semibold"> </Label>
<div className="grid grid-cols-3 gap-2">
<div>
<Label className="text-xs">X</Label>
<Input
type="number"
step="0.5"
value={selectedPlacement.position_x}
onChange={(e) =>
handlePlacementUpdate(selectedPlacement.id, {
position_x: parseFloat(e.target.value),
})
}
/>
</div>
<div>
<Label className="text-xs">Y</Label>
<Input
type="number"
step="0.5"
value={selectedPlacement.position_y}
onChange={(e) =>
handlePlacementUpdate(selectedPlacement.id, {
position_y: parseFloat(e.target.value),
})
}
/>
</div>
<div>
<Label className="text-xs">Z</Label>
<Input
type="number"
step="0.5"
value={selectedPlacement.position_z}
onChange={(e) =>
handlePlacementUpdate(selectedPlacement.id, {
position_z: parseFloat(e.target.value),
})
}
/>
</div>
</div>
<div className="grid grid-cols-3 gap-2">
<div>
<Label className="text-xs"></Label>
<Input
type="number"
step="1"
value={selectedPlacement.size_x}
onChange={(e) =>
handlePlacementUpdate(selectedPlacement.id, {
size_x: parseFloat(e.target.value),
})
}
/>
</div>
<div>
<Label className="text-xs"></Label>
<Input
type="number"
step="1"
value={selectedPlacement.size_y}
onChange={(e) =>
handlePlacementUpdate(selectedPlacement.id, {
size_y: parseFloat(e.target.value),
})
}
/>
</div>
<div>
<Label className="text-xs"></Label>
<Input
type="number"
step="1"
value={selectedPlacement.size_z}
onChange={(e) =>
handlePlacementUpdate(selectedPlacement.id, {
size_z: parseFloat(e.target.value),
})
}
/>
</div>
</div>
<div>
<Label className="text-xs"></Label>
<Input
type="color"
value={selectedPlacement.color}
onChange={(e) => handlePlacementUpdate(selectedPlacement.id, { color: e.target.value })}
/>
</div>
</div>
<Button
variant="destructive"
size="sm"
className="w-full"
onClick={() => handlePlacementRemove(selectedPlacement.id)}
>
</Button>
</div>
</div>
</div>
) : (
// 자재 목록
<div className="flex h-full flex-col">
<div className="border-b p-4">
<h3 className="mb-2 text-sm font-semibold"> </h3>
<Input
placeholder="자재 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="text-sm"
/>
</div>
<div className="flex-1 overflow-auto">
{filteredMaterials.length === 0 ? (
<div className="flex h-full items-center justify-center p-4 text-center text-sm text-gray-500">
</div>
) : (
<div className="p-2">
{filteredMaterials.map((material) => {
const isPlaced = placements.some((p) => p.material_code === material.material_code);
return (
<button
key={material.id}
onClick={() => !isPlaced && handleMaterialClick(material)}
disabled={isPlaced}
className={`mb-2 w-full rounded-lg border p-3 text-left transition-all ${
isPlaced
? "cursor-not-allowed border-gray-200 bg-gray-50 opacity-50"
: "cursor-pointer border-gray-200 bg-white hover:border-blue-500 hover:shadow-sm"
}`}
>
<div className="mb-1 text-sm font-medium text-gray-900">{material.material_name}</div>
<div className="text-xs text-gray-500">{material.material_code}</div>
<div className="mt-1 text-xs text-gray-400">{material.category}</div>
{isPlaced && <div className="mt-1 text-xs text-blue-600"></div>}
</button>
);
})}
</div>
)}
</div>
</div>
)}
</div>
</div>
</div>
);
}

View File

@ -1,132 +0,0 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Loader2 } from "lucide-react";
interface YardLayoutCreateModalProps {
isOpen: boolean;
onClose: () => void;
onCreate: (name: string, description: string) => Promise<void>;
}
export default function YardLayoutCreateModal({ isOpen, onClose, onCreate }: YardLayoutCreateModalProps) {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [isCreating, setIsCreating] = useState(false);
const [error, setError] = useState("");
// 생성 실행
const handleCreate = async () => {
if (!name.trim()) {
setError("야드 이름을 입력하세요");
return;
}
setIsCreating(true);
setError("");
try {
await onCreate(name.trim(), description.trim());
setName("");
setDescription("");
} catch (error: any) {
console.error("야드 생성 실패:", error);
setError(error.message || "야드 생성에 실패했습니다");
} finally {
setIsCreating(false);
}
};
// 모달 닫기
const handleClose = () => {
if (isCreating) return;
setName("");
setDescription("");
setError("");
onClose();
};
// Enter 키 처리
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleCreate();
}
};
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription> </DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* 야드 이름 */}
<div className="space-y-2">
<Label htmlFor="yard-name">
<span className="text-red-500">*</span>
</Label>
<Input
id="yard-name"
value={name}
onChange={(e) => {
setName(e.target.value);
setError("");
}}
onKeyDown={handleKeyDown}
placeholder="예: A구역, 1번 야드"
disabled={isCreating}
autoFocus
/>
</div>
{/* 설명 */}
<div className="space-y-2">
<Label htmlFor="yard-description"></Label>
<Textarea
id="yard-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="야드에 대한 설명을 입력하세요 (선택사항)"
rows={3}
disabled={isCreating}
/>
</div>
{/* 에러 메시지 */}
{error && <div className="rounded-md bg-red-50 p-3 text-sm text-red-600">{error}</div>}
</div>
<DialogFooter>
<Button variant="outline" onClick={handleClose} disabled={isCreating}>
</Button>
<Button onClick={handleCreate} disabled={!name.trim() || isCreating}>
{isCreating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
"생성"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -1,277 +0,0 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Search, MoreVertical, Loader2 } from "lucide-react";
interface YardLayout {
id: number;
name: string;
description: string;
placement_count: number;
updated_at: string;
}
interface YardLayoutListProps {
layouts: YardLayout[];
isLoading: boolean;
onSelect: (layout: YardLayout) => void;
onDelete: (id: number) => Promise<void>;
onDuplicate: (id: number, newName: string) => Promise<void>;
}
export default function YardLayoutList({ layouts, isLoading, onSelect, onDelete, onDuplicate }: YardLayoutListProps) {
const [searchText, setSearchText] = useState("");
const [sortOrder, setSortOrder] = useState<"recent" | "name">("recent");
const [deleteTarget, setDeleteTarget] = useState<YardLayout | null>(null);
const [duplicateTarget, setDuplicateTarget] = useState<YardLayout | null>(null);
const [duplicateName, setDuplicateName] = useState("");
const [isDeleting, setIsDeleting] = useState(false);
const [isDuplicating, setIsDuplicating] = useState(false);
// 검색 필터링
const filteredLayouts = layouts.filter((layout) => {
if (!searchText) return true;
return (
layout.name.toLowerCase().includes(searchText.toLowerCase()) ||
layout.description?.toLowerCase().includes(searchText.toLowerCase())
);
});
// 정렬
const sortedLayouts = [...filteredLayouts].sort((a, b) => {
if (sortOrder === "recent") {
return new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime();
} else {
return a.name.localeCompare(b.name);
}
});
// 날짜 포맷팅
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleString("ko-KR", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
};
// 삭제 확인
const handleDeleteConfirm = async () => {
if (!deleteTarget) return;
setIsDeleting(true);
try {
await onDelete(deleteTarget.id);
setDeleteTarget(null);
} catch (error) {
console.error("삭제 실패:", error);
} finally {
setIsDeleting(false);
}
};
// 복제 실행
const handleDuplicateConfirm = async () => {
if (!duplicateTarget || !duplicateName.trim()) return;
setIsDuplicating(true);
try {
await onDuplicate(duplicateTarget.id, duplicateName);
setDuplicateTarget(null);
setDuplicateName("");
} catch (error) {
console.error("복제 실패:", error);
} finally {
setIsDuplicating(false);
}
};
// 복제 모달 열기
const handleDuplicateClick = (layout: YardLayout) => {
setDuplicateTarget(layout);
setDuplicateName(`${layout.name} (복사본)`);
};
if (isLoading) {
return (
<div className="flex h-full items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
</div>
);
}
return (
<div className="flex h-full flex-col space-y-4">
{/* 검색 및 정렬 */}
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
<Input
placeholder="야드 이름 또는 설명 검색..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="pl-9"
/>
</div>
<select
value={sortOrder}
onChange={(e) => setSortOrder(e.target.value as "recent" | "name")}
className="rounded-md border border-gray-300 px-3 py-2 text-sm"
>
<option value="recent"> </option>
<option value="name"></option>
</select>
</div>
{/* 테이블 */}
{sortedLayouts.length === 0 ? (
<div className="flex flex-1 items-center justify-center">
<div className="text-center text-gray-500">
{searchText ? "검색 결과가 없습니다" : "등록된 야드가 없습니다"}
</div>
</div>
) : (
<div className="flex-1 overflow-auto rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-center"> </TableHead>
<TableHead> </TableHead>
<TableHead className="w-[80px] text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedLayouts.map((layout) => (
<TableRow key={layout.id} className="cursor-pointer hover:bg-gray-50" onClick={() => onSelect(layout)}>
<TableCell className="font-medium">{layout.name}</TableCell>
<TableCell className="text-gray-600">{layout.description || "-"}</TableCell>
<TableCell className="text-center">{layout.placement_count}</TableCell>
<TableCell className="text-sm text-gray-500">{formatDate(layout.updated_at)}</TableCell>
<TableCell className="text-center">
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
<Button variant="ghost" size="sm">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onSelect(layout)}></DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDuplicateClick(layout)}></DropdownMenuItem>
<DropdownMenuItem onClick={() => setDeleteTarget(layout)} className="text-red-600">
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{/* 총 개수 */}
<div className="text-sm text-gray-500"> {sortedLayouts.length}</div>
{/* 삭제 확인 모달 */}
<AlertDialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
&quot;{deleteTarget?.name}&quot; ?
<br />
.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}></AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteConfirm}
disabled={isDeleting}
className="bg-red-600 hover:bg-red-700"
>
{isDeleting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
"삭제"
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 복제 모달 */}
<Dialog open={!!duplicateTarget} onOpenChange={() => setDuplicateTarget(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription> </DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="duplicate-name"> </Label>
<Input
id="duplicate-name"
value={duplicateName}
onChange={(e) => setDuplicateName(e.target.value)}
placeholder="야드 이름을 입력하세요"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDuplicateTarget(null)} disabled={isDuplicating}>
</Button>
<Button onClick={handleDuplicateConfirm} disabled={!duplicateName.trim() || isDuplicating}>
{isDuplicating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
"복제"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -3,7 +3,6 @@
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { DashboardElement, QueryResult } from "@/components/admin/dashboard/types";
import { ChartRenderer } from "@/components/admin/dashboard/charts/ChartRenderer";
import { DashboardProvider } from "@/contexts/DashboardContext";
import { RESOLUTIONS, Resolution } from "@/components/admin/dashboard/ResolutionSelector";
import dynamic from "next/dynamic";
@ -39,9 +38,13 @@ const ListWidget = dynamic(
{ ssr: false },
);
const YardManagement3DWidget = dynamic(() => import("@/components/admin/dashboard/widgets/YardManagement3DWidget"), {
ssr: false,
});
const Warehouse3DWidget = dynamic(
() =>
import("@/components/admin/dashboard/widgets/Warehouse3DWidget").then((mod) => ({
default: mod.Warehouse3DWidget,
})),
{ ssr: false },
);
/**
* - DashboardSidebar의 subtype
@ -81,8 +84,8 @@ function renderWidget(element: DashboardElement) {
case "list":
return <ListWidget element={element} />;
case "yard-management-3d":
return <YardManagement3DWidget isEditMode={false} config={element.yardConfig} />;
case "warehouse-3d":
return <Warehouse3DWidget element={element} />;
// === 차량 관련 (추가 위젯) ===
case "vehicle-status":
@ -119,9 +122,7 @@ function renderWidget(element: DashboardElement) {
interface DashboardViewerProps {
elements: DashboardElement[];
dashboardId?: string;
refreshInterval?: number; // 전체 대시보드 새로고침 간격 (ms)
backgroundColor?: string; // 배경색
resolution?: string; // 대시보드 해상도
}
@ -131,13 +132,7 @@ interface DashboardViewerProps {
* -
* - ( , )
*/
export function DashboardViewer({
elements,
dashboardId,
refreshInterval,
backgroundColor = "#f9fafb",
resolution = "fhd",
}: DashboardViewerProps) {
export function DashboardViewer({ elements, refreshInterval, resolution = "fhd" }: DashboardViewerProps) {
const [elementData, setElementData] = useState<Record<string, QueryResult>>({});
const [loadingElements, setLoadingElements] = useState<Set<string>>(new Set());
@ -255,32 +250,28 @@ export function DashboardViewer({
}
return (
<DashboardProvider>
{/* overflow-auto 제거 - 외부 페이지 스크롤 사용 */}
<div className="flex h-full items-start justify-center bg-gray-100 p-8">
{/* 고정 크기 캔버스 (편집 화면과 동일한 레이아웃) */}
<div
className="relative overflow-hidden rounded-lg"
style={{
width: `${canvasConfig.width}px`,
minHeight: `${canvasConfig.height}px`,
height: `${canvasHeight}px`,
backgroundColor: backgroundColor,
}}
>
{/* 대시보드 요소들 */}
{elements.map((element) => (
<ViewerElement
key={element.id}
element={element}
data={elementData[element.id]}
isLoading={loadingElements.has(element.id)}
onRefresh={() => loadElementData(element)}
/>
))}
</div>
<div className="flex h-full items-start justify-center overflow-auto bg-gray-100 p-8">
{/* 고정 크기 캔버스 (편집 화면과 동일한 레이아웃) */}
<div
className="relative overflow-hidden rounded-lg"
style={{
width: `${canvasConfig.width}px`,
minHeight: `${canvasConfig.height}px`,
height: `${canvasHeight}px`,
}}
>
{/* 대시보드 요소들 */}
{elements.map((element) => (
<ViewerElement
key={element.id}
element={element}
data={elementData[element.id]}
isLoading={loadingElements.has(element.id)}
onRefresh={() => loadElementData(element)}
/>
))}
</div>
</DashboardProvider>
</div>
);
}
@ -314,21 +305,21 @@ function ViewerElement({ element, data, isLoading, onRefresh }: ViewerElementPro
<div className="flex items-center justify-between border-b border-gray-200 bg-gray-50 px-4 py-3">
<h3 className="text-sm font-semibold text-gray-800">{element.customTitle || element.title}</h3>
{/* 새로고침 버튼 (항상 렌더링하되 opacity로 제어) */}
<button
onClick={onRefresh}
disabled={isLoading}
className={`hover:text-muted-foreground text-gray-400 transition-opacity disabled:opacity-50 ${
isHovered ? "opacity-100" : "opacity-0"
}`}
title="새로고침"
>
{isLoading ? (
<div className="h-4 w-4 animate-spin rounded-full border border-gray-400 border-t-transparent" />
) : (
"🔄"
)}
</button>
{/* 새로고침 버튼 (호버 시에만 표시) */}
{isHovered && (
<button
onClick={onRefresh}
disabled={isLoading}
className="hover:text-muted-foreground text-gray-400 disabled:opacity-50"
title="새로고침"
>
{isLoading ? (
<div className="h-4 w-4 animate-spin rounded-full border border-gray-400 border-t-transparent" />
) : (
"🔄"
)}
</button>
)}
</div>
)}

View File

@ -161,7 +161,7 @@ export default function BookingAlertWidget({ element }: BookingAlertWidgetProps)
<div className="border-b border-gray-200 bg-white px-4 py-3">
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<h3 className="text-lg font-bold text-gray-800">{element?.customTitle || "예약 요청 알림"}</h3>
<h3 className="text-lg font-bold text-gray-800">🔔 {element?.customTitle || "예약 요청 알림"}</h3>
{newCount > 0 && (
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-red-500 text-xs font-bold text-white">
{newCount}

View File

@ -7,7 +7,7 @@
* -
*/
import React, { useState, useEffect } from 'react';
import React, { useState } from 'react';
import { Button } from '@/components/ui/button';
import { DashboardElement } from '@/components/admin/dashboard/types';
@ -117,62 +117,11 @@ export default function CalculatorWidget({ element, className = '' }: Calculator
setDisplay(String(value / 100));
};
// 키보드 입력 처리
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
const key = event.key;
// 숫자 키 (0-9)
if (/^[0-9]$/.test(key)) {
event.preventDefault();
handleNumber(key);
}
// 연산자 키
else if (key === '+' || key === '-' || key === '*' || key === '/') {
event.preventDefault();
handleOperation(key);
}
// 소수점
else if (key === '.') {
event.preventDefault();
handleDecimal();
}
// Enter 또는 = (계산)
else if (key === 'Enter' || key === '=') {
event.preventDefault();
handleEquals();
}
// Escape 또는 c (초기화)
else if (key === 'Escape' || key.toLowerCase() === 'c') {
event.preventDefault();
handleClear();
}
// Backspace (지우기)
else if (key === 'Backspace') {
event.preventDefault();
handleBackspace();
}
// % (퍼센트)
else if (key === '%') {
event.preventDefault();
handlePercent();
}
};
// 이벤트 리스너 등록
window.addEventListener('keydown', handleKeyDown);
// 클린업
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [display, previousValue, operation, waitingForOperand]);
return (
<div className={`h-full w-full p-3 bg-gradient-to-br from-slate-50 to-gray-100 ${className}`}>
<div className="h-full flex flex-col gap-2">
{/* 제목 */}
<h3 className="text-base font-semibold text-gray-900 text-center">{element?.customTitle || "계산기"}</h3>
<h3 className="text-base font-semibold text-gray-900 text-center">🧮 {element?.customTitle || "계산기"}</h3>
{/* 디스플레이 */}
<div className="bg-white border-2 border-gray-200 rounded-lg p-4 shadow-inner min-h-[80px]">

View File

@ -150,7 +150,7 @@ export default function CustomerIssuesWidget({ element }: CustomerIssuesWidgetPr
<div className="flex h-full flex-col overflow-hidden bg-background p-4">
{/* 헤더 */}
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-semibold text-foreground"> /</h3>
<h3 className="text-lg font-semibold text-foreground"> /</h3>
<button
onClick={loadData}
className="rounded-full p-1 text-muted-foreground hover:bg-accent hover:text-accent-foreground"

View File

@ -130,7 +130,7 @@ export default function DeliveryTodayStatsWidget({ element }: DeliveryTodayStats
<div className="flex h-full flex-col overflow-hidden bg-white p-4">
{/* 헤더 */}
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-800"> </h3>
<h3 className="text-lg font-semibold text-gray-800">📅 </h3>
<button
onClick={loadData}
className="rounded-full p-1 text-gray-500 hover:bg-gray-100"

View File

@ -132,7 +132,7 @@ export default function DocumentWidget({ element }: DocumentWidgetProps) {
{/* 헤더 */}
<div className="border-b border-gray-200 bg-white px-4 py-3">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-lg font-bold text-gray-800">{element?.customTitle || "문서 관리"}</h3>
<h3 className="text-lg font-bold text-gray-800">📂 {element?.customTitle || "문서 관리"}</h3>
<button className="rounded-lg bg-primary px-3 py-1.5 text-sm text-white transition-colors hover:bg-primary/90">
+
</button>

View File

@ -135,11 +135,11 @@ export default function ExchangeWidget({
const hasError = error || !exchangeRate;
return (
<div className="h-full bg-gradient-to-br from-green-50 to-emerald-50 rounded-lg border p-4 @container">
<div className="h-full bg-gradient-to-br from-green-50 to-emerald-50 rounded-lg border p-4">
{/* 헤더 */}
<div className="flex items-center justify-between mb-3">
<div className="flex-1">
<h3 className="text-base font-semibold text-gray-900 mb-1">{element?.customTitle || "환율"}</h3>
<h3 className="text-base font-semibold text-gray-900 mb-1">💱 {element?.customTitle || "환율"}</h3>
<p className="text-xs text-gray-500">
{lastUpdated
? `업데이트: ${lastUpdated.toLocaleTimeString('ko-KR', {
@ -160,10 +160,10 @@ export default function ExchangeWidget({
</Button>
</div>
{/* 통화 선택 - 반응형 (좁을 때 세로 배치) */}
<div className="flex @[300px]:flex-row flex-col items-center gap-2 mb-3">
{/* 통화 선택 */}
<div className="flex items-center gap-2 mb-3">
<Select value={base} onValueChange={setBase}>
<SelectTrigger className="w-full @[300px]:flex-1 bg-white h-8 text-xs">
<SelectTrigger className="flex-1 bg-white h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
@ -179,13 +179,13 @@ export default function ExchangeWidget({
variant="ghost"
size="sm"
onClick={handleSwap}
className="h-8 w-8 p-0 rounded-full hover:bg-white @[300px]:rotate-0 rotate-90"
className="h-8 w-8 p-0 rounded-full hover:bg-white"
>
<ArrowRightLeft className="h-3 w-3" />
</Button>
<Select value={target} onValueChange={setTarget}>
<SelectTrigger className="w-full @[300px]:flex-1 bg-white h-8 text-xs">
<SelectTrigger className="flex-1 bg-white h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>

View File

@ -158,7 +158,7 @@ export default function MapSummaryWidget({ element }: MapSummaryWidgetProps) {
{/* 헤더 */}
<div className="mb-2 flex flex-shrink-0 items-center justify-between">
<div className="flex-1">
<h3 className="text-sm font-bold text-gray-900">{displayTitle}</h3>
<h3 className="text-sm font-bold text-gray-900">📍 {displayTitle}</h3>
{element?.dataSource?.query ? (
<p className="text-xs text-gray-500"> {markers.length.toLocaleString()} </p>
) : (

View File

@ -1,9 +1,8 @@
"use client";
import React, { useState, useEffect } from "react";
import { Plus, Check, X, Clock, AlertCircle, GripVertical, ChevronDown, Calendar as CalendarIcon } from "lucide-react";
import { Plus, Check, X, Clock, AlertCircle, GripVertical, ChevronDown } from "lucide-react";
import { DashboardElement } from "@/components/admin/dashboard/types";
import { useDashboard } from "@/contexts/DashboardContext";
interface TodoItem {
id: string;
@ -34,9 +33,6 @@ interface TodoWidgetProps {
}
export default function TodoWidget({ element }: TodoWidgetProps) {
// Context에서 선택된 날짜 가져오기
const { selectedDate } = useDashboard();
const [todos, setTodos] = useState<TodoItem[]>([]);
const [stats, setStats] = useState<TodoStats | null>(null);
const [loading, setLoading] = useState(true);
@ -55,85 +51,22 @@ export default function TodoWidget({ element }: TodoWidgetProps) {
fetchTodos();
const interval = setInterval(fetchTodos, 30000); // 30초마다 갱신
return () => clearInterval(interval);
}, [filter, selectedDate]); // selectedDate도 의존성에 추가
}, [filter]);
const fetchTodos = async () => {
try {
const token = localStorage.getItem("authToken");
const userLang = localStorage.getItem("userLang") || "KR";
// 외부 DB 조회 (dataSource가 설정된 경우)
if (element?.dataSource?.query) {
// console.log("🔍 TodoWidget - 외부 DB 조회 시작");
// console.log("📝 Query:", element.dataSource.query);
// console.log("🔗 ConnectionId:", element.dataSource.externalConnectionId);
// console.log("🔗 ConnectionType:", element.dataSource.connectionType);
// 현재 DB vs 외부 DB 분기
const apiUrl = element.dataSource.connectionType === "external" && element.dataSource.externalConnectionId
? `http://localhost:9771/api/external-db/query?userLang=${userLang}`
: `http://localhost:9771/api/dashboards/execute-query?userLang=${userLang}`;
const requestBody = element.dataSource.connectionType === "external" && element.dataSource.externalConnectionId
? {
connectionId: parseInt(element.dataSource.externalConnectionId),
query: element.dataSource.query,
}
: {
query: element.dataSource.query,
};
const filterParam = filter !== "all" ? `?status=${filter}` : "";
const response = await fetch(`http://localhost:9771/api/todos${filterParam}`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
// console.log("🌐 API URL:", apiUrl);
// console.log("📦 Request Body:", requestBody);
const response = await fetch(apiUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(requestBody),
});
// console.log("📡 Response status:", response.status);
if (response.ok) {
const result = await response.json();
// console.log("✅ API 응답:", result);
// console.log("📦 result.data:", result.data);
// console.log("📦 result.data.rows:", result.data?.rows);
// API 응답 형식에 따라 데이터 추출
const rows = result.data?.rows || result.data || [];
// console.log("📊 추출된 rows:", rows);
const externalTodos = mapExternalDataToTodos(rows);
// console.log("📋 변환된 Todos:", externalTodos);
// console.log("📋 변환된 Todos 개수:", externalTodos.length);
setTodos(externalTodos);
setStats(calculateStatsFromTodos(externalTodos));
// console.log("✅ setTodos, setStats 호출 완료!");
} else {
const errorText = await response.text();
// console.error("❌ API 오류:", errorText);
}
}
// 내장 API 조회 (기본)
else {
const filterParam = filter !== "all" ? `?status=${filter}` : "";
const response = await fetch(`http://localhost:9771/api/todos${filterParam}`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (response.ok) {
const result = await response.json();
setTodos(result.data || []);
setStats(result.stats);
}
if (response.ok) {
const result = await response.json();
setTodos(result.data || []);
setStats(result.stats);
}
} catch (error) {
// console.error("To-Do 로딩 오류:", error);
@ -142,48 +75,8 @@ export default function TodoWidget({ element }: TodoWidgetProps) {
}
};
// 외부 DB 데이터를 TodoItem 형식으로 변환
const mapExternalDataToTodos = (data: any[]): TodoItem[] => {
return data.map((row, index) => ({
id: row.id || `todo-${index}`,
title: row.title || row.task || row.name || "제목 없음",
description: row.description || row.desc || row.content,
priority: row.priority || "normal",
status: row.status || "pending",
assignedTo: row.assigned_to || row.assignedTo || row.user,
dueDate: row.due_date || row.dueDate || row.deadline,
createdAt: row.created_at || row.createdAt || new Date().toISOString(),
updatedAt: row.updated_at || row.updatedAt || new Date().toISOString(),
completedAt: row.completed_at || row.completedAt,
isUrgent: row.is_urgent || row.isUrgent || row.urgent || false,
order: row.display_order || row.order || index,
}));
};
// Todo 배열로부터 통계 계산
const calculateStatsFromTodos = (todoList: TodoItem[]): TodoStats => {
return {
total: todoList.length,
pending: todoList.filter((t) => t.status === "pending").length,
inProgress: todoList.filter((t) => t.status === "in_progress").length,
completed: todoList.filter((t) => t.status === "completed").length,
urgent: todoList.filter((t) => t.isUrgent).length,
overdue: todoList.filter((t) => {
if (!t.dueDate) return false;
return new Date(t.dueDate) < new Date() && t.status !== "completed";
}).length,
};
};
// 외부 DB 조회 여부 확인
const isExternalData = !!element?.dataSource?.query;
const handleAddTodo = async () => {
if (!newTodo.title.trim()) return;
if (isExternalData) {
alert("외부 데이터베이스 조회 모드에서는 추가할 수 없습니다.");
return;
}
try {
const token = localStorage.getItem("authToken");
@ -292,27 +185,6 @@ export default function TodoWidget({ element }: TodoWidgetProps) {
return "⚠️ 오늘 마감";
};
// 선택된 날짜로 필터링
const filteredTodos = selectedDate
? todos.filter((todo) => {
if (!todo.dueDate) return false;
const todoDate = new Date(todo.dueDate);
return (
todoDate.getFullYear() === selectedDate.getFullYear() &&
todoDate.getMonth() === selectedDate.getMonth() &&
todoDate.getDate() === selectedDate.getDate()
);
})
: todos;
const formatSelectedDate = () => {
if (!selectedDate) return null;
const year = selectedDate.getFullYear();
const month = selectedDate.getMonth() + 1;
const day = selectedDate.getDate();
return `${year}${month}${day}`;
};
if (loading) {
return (
<div className="flex h-full items-center justify-center">
@ -323,70 +195,58 @@ export default function TodoWidget({ element }: TodoWidgetProps) {
return (
<div className="flex h-full flex-col bg-gradient-to-br from-slate-50 to-blue-50">
{/* 제목 - 항상 표시 */}
<div className="border-b border-gray-200 bg-white px-4 py-2">
<h3 className="text-lg font-bold text-gray-800">{element?.customTitle || "To-Do / 긴급 지시"}</h3>
{selectedDate && (
<div className="mt-1 flex items-center gap-1 text-xs text-green-600">
<CalendarIcon className="h-3 w-3" />
<span className="font-semibold">{formatSelectedDate()} </span>
{/* 헤더 */}
<div className="border-b border-gray-200 bg-white px-4 py-3">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-lg font-bold text-gray-800"> {element?.customTitle || "To-Do / 긴급 지시"}</h3>
<button
onClick={() => setShowAddForm(!showAddForm)}
className="flex items-center gap-1 rounded-lg bg-primary px-3 py-1.5 text-sm text-white transition-colors hover:bg-primary/90"
>
<Plus className="h-4 w-4" />
</button>
</div>
{/* 통계 */}
{stats && (
<div className="grid grid-cols-4 gap-2 text-xs">
<div className="rounded bg-blue-50 px-2 py-1.5 text-center">
<div className="font-bold text-blue-700">{stats.pending}</div>
<div className="text-blue-600"></div>
</div>
<div className="rounded bg-amber-50 px-2 py-1.5 text-center">
<div className="font-bold text-amber-700">{stats.inProgress}</div>
<div className="text-amber-600"></div>
</div>
<div className="rounded bg-red-50 px-2 py-1.5 text-center">
<div className="font-bold text-red-700">{stats.urgent}</div>
<div className="text-red-600"></div>
</div>
<div className="rounded bg-rose-50 px-2 py-1.5 text-center">
<div className="font-bold text-rose-700">{stats.overdue}</div>
<div className="text-rose-600"></div>
</div>
</div>
)}
</div>
{/* 헤더 (추가 버튼, 통계, 필터) - showHeader가 false일 때만 숨김 */}
{element?.showHeader !== false && (
<div className="border-b border-gray-200 bg-white px-4 py-3">
<div className="mb-3 flex items-center justify-end">
{/* 필터 */}
<div className="mt-3 flex gap-2">
{(["all", "pending", "in_progress", "completed"] as const).map((f) => (
<button
onClick={() => setShowAddForm(!showAddForm)}
className="flex items-center gap-1 rounded-lg bg-primary px-3 py-1.5 text-sm text-white transition-colors hover:bg-primary/90"
key={f}
onClick={() => setFilter(f)}
className={`rounded px-3 py-1 text-xs font-medium transition-colors ${
filter === f
? "bg-primary text-white"
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
}`}
>
<Plus className="h-4 w-4" />
{f === "all" ? "전체" : f === "pending" ? "대기" : f === "in_progress" ? "진행중" : "완료"}
</button>
</div>
{/* 통계 */}
{stats && (
<div className="grid grid-cols-4 gap-2 text-xs mb-3">
<div className="rounded bg-blue-50 px-2 py-1.5 text-center">
<div className="font-bold text-blue-700">{stats.pending}</div>
<div className="text-blue-600"></div>
</div>
<div className="rounded bg-amber-50 px-2 py-1.5 text-center">
<div className="font-bold text-amber-700">{stats.inProgress}</div>
<div className="text-amber-600"></div>
</div>
<div className="rounded bg-red-50 px-2 py-1.5 text-center">
<div className="font-bold text-red-700">{stats.urgent}</div>
<div className="text-red-600"></div>
</div>
<div className="rounded bg-rose-50 px-2 py-1.5 text-center">
<div className="font-bold text-rose-700">{stats.overdue}</div>
<div className="text-rose-600"></div>
</div>
</div>
)}
{/* 필터 */}
<div className="flex gap-2">
{(["all", "pending", "in_progress", "completed"] as const).map((f) => (
<button
key={f}
onClick={() => setFilter(f)}
className={`rounded px-3 py-1 text-xs font-medium transition-colors ${
filter === f
? "bg-primary text-white"
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
}`}
>
{f === "all" ? "전체" : f === "pending" ? "대기" : f === "in_progress" ? "진행중" : "완료"}
</button>
))}
</div>
))}
</div>
)}
</div>
{/* 추가 폼 */}
{showAddForm && (
@ -455,16 +315,16 @@ export default function TodoWidget({ element }: TodoWidgetProps) {
{/* To-Do 리스트 */}
<div className="flex-1 overflow-y-auto p-4">
{filteredTodos.length === 0 ? (
{todos.length === 0 ? (
<div className="flex h-full items-center justify-center text-gray-400">
<div className="text-center">
<div className="mb-2 text-4xl">📝</div>
<div>{selectedDate ? `${formatSelectedDate()} 할 일이 없습니다` : "할 일이 없습니다"}</div>
<div> </div>
</div>
</div>
) : (
<div className="space-y-2">
{filteredTodos.map((todo) => (
{todos.map((todo) => (
<div
key={todo.id}
className={`group relative rounded-lg border-2 bg-white p-3 shadow-sm transition-all hover:shadow-md ${

View File

@ -97,7 +97,7 @@ export default function VehicleListWidget({ element, refreshInterval = 30000 }:
{/* 헤더 */}
<div className="mb-3 flex items-center justify-between">
<div>
<h3 className="text-lg font-bold text-gray-900"> </h3>
<h3 className="text-lg font-bold text-gray-900">📋 </h3>
<p className="text-xs text-gray-500"> : {lastUpdate.toLocaleTimeString("ko-KR")}</p>
</div>
<Button variant="outline" size="sm" onClick={loadVehicles} disabled={isLoading} className="h-8 w-8 p-0">

View File

@ -280,12 +280,9 @@ export default function WeatherWidget({
if (loading && !weather) {
return (
<div className="flex h-full items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-50 rounded-lg border p-6">
<div className="flex flex-col items-center gap-3">
<div className="flex flex-col items-center gap-2">
<RefreshCw className="h-8 w-8 animate-spin text-blue-500" />
<div className="text-center">
<p className="text-sm font-semibold text-gray-800 mb-1"> API ...</p>
<p className="text-xs text-gray-500"> </p>
</div>
<p className="text-sm text-gray-600"> ...</p>
</div>
</div>
);
@ -293,27 +290,10 @@ export default function WeatherWidget({
// 에러 상태
if (error || !weather) {
const isTestMode = error?.includes('API 키가 설정되지 않았습니다');
return (
<div className={`flex h-full flex-col items-center justify-center rounded-lg border p-6 ${
isTestMode
? 'bg-gradient-to-br from-yellow-50 to-orange-50'
: 'bg-gradient-to-br from-red-50 to-orange-50'
}`}>
<div className="flex h-full flex-col items-center justify-center bg-gradient-to-br from-red-50 to-orange-50 rounded-lg border p-6">
<Cloud className="h-12 w-12 text-gray-400 mb-2" />
<div className="text-center mb-3">
<p className="text-sm font-semibold text-gray-800 mb-1">
{isTestMode ? '⚠️ 테스트 모드' : '❌ 연결 실패'}
</p>
<p className="text-xs text-gray-600">
{error || '날씨 정보를 불러올 수 없습니다.'}
</p>
{isTestMode && (
<p className="text-xs text-yellow-700 mt-2">
</p>
)}
</div>
<p className="text-sm text-gray-600 text-center mb-3">{error || '날씨 정보를 불러올 수 없습니다.'}</p>
<Button
variant="outline"
size="sm"

View File

@ -457,7 +457,7 @@ function AppLayoutInner({ children }: AppLayoutProps) {
</aside>
{/* 가운데 컨텐츠 영역 - overflow 문제 해결 */}
<main className="min-w-0 flex-1 bg-white">{children}</main>
<main className="min-w-0 flex-1 overflow-auto bg-white">{children}</main>
</div>
{/* 프로필 수정 모달 */}

View File

@ -1,34 +0,0 @@
"use client";
import React, { createContext, useContext, useState, ReactNode } from "react";
/**
* Context
* - /
*/
interface DashboardContextType {
selectedDate: Date | null;
setSelectedDate: (date: Date | null) => void;
}
const DashboardContext = createContext<DashboardContextType | undefined>(undefined);
export function DashboardProvider({ children }: { children: ReactNode }) {
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
return (
<DashboardContext.Provider value={{ selectedDate, setSelectedDate }}>
{children}
</DashboardContext.Provider>
);
}
export function useDashboard() {
const context = useContext(DashboardContext);
if (context === undefined) {
throw new Error("useDashboard must be used within a DashboardProvider");
}
return context;
}

View File

@ -1,84 +0,0 @@
import { apiCall } from "./client";
// 야드 레이아웃 관리 API
export const yardLayoutApi = {
// 모든 야드 레이아웃 목록 조회
async getAllLayouts() {
return apiCall("GET", "/yard-layouts");
},
// 특정 야드 레이아웃 상세 조회
async getLayoutById(id: number) {
return apiCall("GET", `/yard-layouts/${id}`);
},
// 새 야드 레이아웃 생성
async createLayout(data: { name: string; description?: string }) {
return apiCall("POST", "/yard-layouts", data);
},
// 야드 레이아웃 수정
async updateLayout(id: number, data: { name?: string; description?: string }) {
return apiCall("PUT", `/yard-layouts/${id}`, data);
},
// 야드 레이아웃 삭제
async deleteLayout(id: number) {
return apiCall("DELETE", `/yard-layouts/${id}`);
},
// 야드 레이아웃 복제
async duplicateLayout(id: number, name: string) {
return apiCall("POST", `/yard-layouts/${id}/duplicate`, { name });
},
// 특정 야드의 배치 자재 목록 조회
async getPlacementsByLayoutId(layoutId: number) {
return apiCall("GET", `/yard-layouts/${layoutId}/placements`);
},
// 야드에 자재 배치 추가
async addMaterialPlacement(layoutId: number, data: any) {
return apiCall("POST", `/yard-layouts/${layoutId}/placements`, data);
},
// 배치 정보 수정
async updatePlacement(placementId: number, data: any) {
return apiCall("PUT", `/yard-layouts/placements/${placementId}`, data);
},
// 배치 해제
async removePlacement(placementId: number) {
return apiCall("DELETE", `/yard-layouts/placements/${placementId}`);
},
// 여러 배치 일괄 업데이트
async batchUpdatePlacements(layoutId: number, placements: any[]) {
return apiCall("PUT", `/yard-layouts/${layoutId}/placements/batch`, { placements });
},
};
// 자재 관리 API
export const materialApi = {
// 임시 자재 마스터 목록 조회
async getTempMaterials(params?: { search?: string; category?: string; page?: number; limit?: number }) {
const queryParams = new URLSearchParams();
if (params?.search) queryParams.append("search", params.search);
if (params?.category) queryParams.append("category", params.category);
if (params?.page) queryParams.append("page", params.page.toString());
if (params?.limit) queryParams.append("limit", params.limit.toString());
const queryString = queryParams.toString();
return apiCall("GET", `/materials/temp${queryString ? `?${queryString}` : ""}`);
},
// 특정 자재 상세 조회
async getTempMaterialByCode(code: string) {
return apiCall("GET", `/materials/temp/${code}`);
},
// 카테고리 목록 조회
async getCategories() {
return apiCall("GET", "/materials/temp/categories");
},
};