Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/dashboard
This commit is contained in:
commit
9168844fab
|
|
@ -321,24 +321,34 @@ export class DashboardService {
|
|||
]);
|
||||
|
||||
// 3. 요소 데이터 변환
|
||||
console.log("📊 대시보드 요소 개수:", elementsResult.rows.length);
|
||||
|
||||
const elements: DashboardElement[] = elementsResult.rows.map(
|
||||
(row: any) => ({
|
||||
id: row.id,
|
||||
type: row.element_type,
|
||||
subtype: row.element_subtype,
|
||||
position: {
|
||||
x: row.position_x,
|
||||
y: row.position_y,
|
||||
},
|
||||
size: {
|
||||
width: row.width,
|
||||
height: row.height,
|
||||
},
|
||||
title: row.title,
|
||||
content: row.content,
|
||||
dataSource: JSON.parse(row.data_source_config || "{}"),
|
||||
chartConfig: JSON.parse(row.chart_config || "{}"),
|
||||
})
|
||||
(row: any, index: number) => {
|
||||
const element = {
|
||||
id: row.id,
|
||||
type: row.element_type,
|
||||
subtype: row.element_subtype,
|
||||
position: {
|
||||
x: row.position_x,
|
||||
y: row.position_y,
|
||||
},
|
||||
size: {
|
||||
width: row.width,
|
||||
height: row.height,
|
||||
},
|
||||
title: row.title,
|
||||
content: row.content,
|
||||
dataSource: JSON.parse(row.data_source_config || "{}"),
|
||||
chartConfig: JSON.parse(row.chart_config || "{}"),
|
||||
};
|
||||
|
||||
console.log(
|
||||
`📊 위젯 #${index + 1}: type="${element.type}", subtype="${element.subtype}", title="${element.title}"`
|
||||
);
|
||||
|
||||
return element;
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -53,13 +53,20 @@ export class BookingService {
|
|||
}
|
||||
|
||||
private ensureDataDirectory(): void {
|
||||
if (!fs.existsSync(BOOKING_DIR)) {
|
||||
fs.mkdirSync(BOOKING_DIR, { recursive: true });
|
||||
logger.info(`📁 예약 데이터 디렉토리 생성: ${BOOKING_DIR}`);
|
||||
}
|
||||
if (!fs.existsSync(BOOKING_FILE)) {
|
||||
fs.writeFileSync(BOOKING_FILE, JSON.stringify([], null, 2));
|
||||
logger.info(`📄 예약 파일 생성: ${BOOKING_FILE}`);
|
||||
try {
|
||||
if (!fs.existsSync(BOOKING_DIR)) {
|
||||
fs.mkdirSync(BOOKING_DIR, { recursive: true, mode: 0o755 });
|
||||
logger.info(`📁 예약 데이터 디렉토리 생성: ${BOOKING_DIR}`);
|
||||
}
|
||||
if (!fs.existsSync(BOOKING_FILE)) {
|
||||
fs.writeFileSync(BOOKING_FILE, JSON.stringify([], null, 2), {
|
||||
mode: 0o644,
|
||||
});
|
||||
logger.info(`📄 예약 파일 생성: ${BOOKING_FILE}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`❌ 예약 디렉토리 생성 실패: ${BOOKING_DIR}`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -111,13 +118,16 @@ export class BookingService {
|
|||
priority?: string;
|
||||
}): Promise<{ bookings: BookingRequest[]; newCount: number }> {
|
||||
try {
|
||||
const bookings = DATA_SOURCE === "database"
|
||||
? await this.loadBookingsFromDB(filter)
|
||||
: this.loadBookingsFromFile(filter);
|
||||
const bookings =
|
||||
DATA_SOURCE === "database"
|
||||
? await this.loadBookingsFromDB(filter)
|
||||
: this.loadBookingsFromFile(filter);
|
||||
|
||||
bookings.sort((a, b) => {
|
||||
if (a.priority !== b.priority) return a.priority === "urgent" ? -1 : 1;
|
||||
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
||||
return (
|
||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
);
|
||||
});
|
||||
|
||||
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000);
|
||||
|
|
@ -145,7 +155,10 @@ export class BookingService {
|
|||
}
|
||||
}
|
||||
|
||||
public async rejectBooking(id: string, reason?: string): Promise<BookingRequest> {
|
||||
public async rejectBooking(
|
||||
id: string,
|
||||
reason?: string
|
||||
): Promise<BookingRequest> {
|
||||
try {
|
||||
if (DATA_SOURCE === "database") {
|
||||
return await this.rejectBookingDB(id, reason);
|
||||
|
|
@ -194,9 +207,15 @@ export class BookingService {
|
|||
scheduledTime: new Date(row.scheduledTime).toISOString(),
|
||||
createdAt: new Date(row.createdAt).toISOString(),
|
||||
updatedAt: new Date(row.updatedAt).toISOString(),
|
||||
acceptedAt: row.acceptedAt ? new Date(row.acceptedAt).toISOString() : undefined,
|
||||
rejectedAt: row.rejectedAt ? new Date(row.rejectedAt).toISOString() : undefined,
|
||||
completedAt: row.completedAt ? new Date(row.completedAt).toISOString() : undefined,
|
||||
acceptedAt: row.acceptedAt
|
||||
? new Date(row.acceptedAt).toISOString()
|
||||
: undefined,
|
||||
rejectedAt: row.rejectedAt
|
||||
? new Date(row.rejectedAt).toISOString()
|
||||
: undefined,
|
||||
completedAt: row.completedAt
|
||||
? new Date(row.completedAt).toISOString()
|
||||
: undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
|
|
@ -230,7 +249,10 @@ export class BookingService {
|
|||
};
|
||||
}
|
||||
|
||||
private async rejectBookingDB(id: string, reason?: string): Promise<BookingRequest> {
|
||||
private async rejectBookingDB(
|
||||
id: string,
|
||||
reason?: string
|
||||
): Promise<BookingRequest> {
|
||||
const rows = await query(
|
||||
`UPDATE booking_requests
|
||||
SET status = 'rejected', rejected_at = NOW(), updated_at = NOW(), rejection_reason = $2
|
||||
|
|
|
|||
|
|
@ -33,11 +33,7 @@ class MailAccountFileService {
|
|||
try {
|
||||
await fs.access(this.accountsDir);
|
||||
} catch {
|
||||
try {
|
||||
await fs.mkdir(this.accountsDir, { recursive: true });
|
||||
} catch (error) {
|
||||
console.error("메일 계정 디렉토리 생성 실패:", error);
|
||||
}
|
||||
await fs.mkdir(this.accountsDir, { recursive: true, mode: 0o755 });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -59,11 +59,7 @@ export class MailReceiveBasicService {
|
|||
try {
|
||||
await fs.access(this.attachmentsDir);
|
||||
} catch {
|
||||
try {
|
||||
await fs.mkdir(this.attachmentsDir, { recursive: true });
|
||||
} catch (error) {
|
||||
console.error("메일 첨부파일 디렉토리 생성 실패:", error);
|
||||
}
|
||||
await fs.mkdir(this.attachmentsDir, { recursive: true, mode: 0o755 });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,15 +20,13 @@ const SENT_MAIL_DIR =
|
|||
|
||||
class MailSentHistoryService {
|
||||
constructor() {
|
||||
// 디렉토리 생성 (없으면) - try-catch로 권한 에러 방지
|
||||
try {
|
||||
if (!fs.existsSync(SENT_MAIL_DIR)) {
|
||||
fs.mkdirSync(SENT_MAIL_DIR, { recursive: true });
|
||||
fs.mkdirSync(SENT_MAIL_DIR, { recursive: true, mode: 0o755 });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("메일 발송 이력 디렉토리 생성 실패:", error);
|
||||
// 디렉토리가 이미 존재하거나 권한이 없어도 서비스는 계속 실행
|
||||
// 실제 파일 쓰기 시점에 에러 처리
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -45,13 +43,15 @@ class MailSentHistoryService {
|
|||
};
|
||||
|
||||
try {
|
||||
// 디렉토리가 없으면 다시 시도
|
||||
if (!fs.existsSync(SENT_MAIL_DIR)) {
|
||||
fs.mkdirSync(SENT_MAIL_DIR, { recursive: true });
|
||||
fs.mkdirSync(SENT_MAIL_DIR, { recursive: true, mode: 0o755 });
|
||||
}
|
||||
|
||||
const filePath = path.join(SENT_MAIL_DIR, `${history.id}.json`);
|
||||
fs.writeFileSync(filePath, JSON.stringify(history, null, 2), "utf-8");
|
||||
fs.writeFileSync(filePath, JSON.stringify(history, null, 2), {
|
||||
encoding: "utf-8",
|
||||
mode: 0o644,
|
||||
});
|
||||
|
||||
console.log("발송 이력 저장:", history.id);
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -54,17 +54,13 @@ class MailTemplateFileService {
|
|||
}
|
||||
|
||||
/**
|
||||
* 템플릿 디렉토리 생성 (없으면) - try-catch로 권한 에러 방지
|
||||
* 템플릿 디렉토리 생성
|
||||
*/
|
||||
private async ensureDirectoryExists() {
|
||||
try {
|
||||
await fs.access(this.templatesDir);
|
||||
} catch {
|
||||
try {
|
||||
await fs.mkdir(this.templatesDir, { recursive: true });
|
||||
} catch (error) {
|
||||
console.error("메일 템플릿 디렉토리 생성 실패:", error);
|
||||
}
|
||||
await fs.mkdir(this.templatesDir, { recursive: true, mode: 0o755 });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -61,13 +61,20 @@ export class TodoService {
|
|||
* 데이터 디렉토리 생성 (파일 모드)
|
||||
*/
|
||||
private ensureDataDirectory(): void {
|
||||
if (!fs.existsSync(TODO_DIR)) {
|
||||
fs.mkdirSync(TODO_DIR, { recursive: true });
|
||||
logger.info(`📁 To-Do 데이터 디렉토리 생성: ${TODO_DIR}`);
|
||||
}
|
||||
if (!fs.existsSync(TODO_FILE)) {
|
||||
fs.writeFileSync(TODO_FILE, JSON.stringify([], null, 2));
|
||||
logger.info(`📄 To-Do 파일 생성: ${TODO_FILE}`);
|
||||
try {
|
||||
if (!fs.existsSync(TODO_DIR)) {
|
||||
fs.mkdirSync(TODO_DIR, { recursive: true, mode: 0o755 });
|
||||
logger.info(`📁 To-Do 데이터 디렉토리 생성: ${TODO_DIR}`);
|
||||
}
|
||||
if (!fs.existsSync(TODO_FILE)) {
|
||||
fs.writeFileSync(TODO_FILE, JSON.stringify([], null, 2), {
|
||||
mode: 0o644,
|
||||
});
|
||||
logger.info(`📄 To-Do 파일 생성: ${TODO_FILE}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`❌ To-Do 디렉토리 생성 실패: ${TODO_DIR}`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -80,15 +87,17 @@ export class TodoService {
|
|||
assignedTo?: string;
|
||||
}): Promise<TodoListResponse> {
|
||||
try {
|
||||
const todos = DATA_SOURCE === "database"
|
||||
? await this.loadTodosFromDB(filter)
|
||||
: this.loadTodosFromFile(filter);
|
||||
const todos =
|
||||
DATA_SOURCE === "database"
|
||||
? await this.loadTodosFromDB(filter)
|
||||
: this.loadTodosFromFile(filter);
|
||||
|
||||
// 정렬: 긴급 > 우선순위 > 순서
|
||||
todos.sort((a, b) => {
|
||||
if (a.isUrgent !== b.isUrgent) return a.isUrgent ? -1 : 1;
|
||||
const priorityOrder = { urgent: 0, high: 1, normal: 2, low: 3 };
|
||||
if (a.priority !== b.priority) return priorityOrder[a.priority] - priorityOrder[b.priority];
|
||||
if (a.priority !== b.priority)
|
||||
return priorityOrder[a.priority] - priorityOrder[b.priority];
|
||||
return a.order - b.order;
|
||||
});
|
||||
|
||||
|
|
@ -124,7 +133,8 @@ export class TodoService {
|
|||
await this.createTodoDB(newTodo);
|
||||
} else {
|
||||
const todos = this.loadTodosFromFile();
|
||||
newTodo.order = todos.length > 0 ? Math.max(...todos.map((t) => t.order)) + 1 : 0;
|
||||
newTodo.order =
|
||||
todos.length > 0 ? Math.max(...todos.map((t) => t.order)) + 1 : 0;
|
||||
todos.push(newTodo);
|
||||
this.saveTodosToFile(todos);
|
||||
}
|
||||
|
|
@ -140,7 +150,10 @@ export class TodoService {
|
|||
/**
|
||||
* To-Do 항목 수정
|
||||
*/
|
||||
public async updateTodo(id: string, updates: Partial<TodoItem>): Promise<TodoItem> {
|
||||
public async updateTodo(
|
||||
id: string,
|
||||
updates: Partial<TodoItem>
|
||||
): Promise<TodoItem> {
|
||||
try {
|
||||
if (DATA_SOURCE === "database") {
|
||||
return await this.updateTodoDB(id, updates);
|
||||
|
|
@ -231,7 +244,9 @@ export class TodoService {
|
|||
dueDate: row.dueDate ? new Date(row.dueDate).toISOString() : undefined,
|
||||
createdAt: new Date(row.createdAt).toISOString(),
|
||||
updatedAt: new Date(row.updatedAt).toISOString(),
|
||||
completedAt: row.completedAt ? new Date(row.completedAt).toISOString() : undefined,
|
||||
completedAt: row.completedAt
|
||||
? new Date(row.completedAt).toISOString()
|
||||
: undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
|
|
@ -263,7 +278,10 @@ export class TodoService {
|
|||
);
|
||||
}
|
||||
|
||||
private async updateTodoDB(id: string, updates: Partial<TodoItem>): Promise<TodoItem> {
|
||||
private async updateTodoDB(
|
||||
id: string,
|
||||
updates: Partial<TodoItem>
|
||||
): Promise<TodoItem> {
|
||||
const setClauses: string[] = ["updated_at = NOW()"];
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
|
@ -327,12 +345,17 @@ export class TodoService {
|
|||
dueDate: row.dueDate ? new Date(row.dueDate).toISOString() : undefined,
|
||||
createdAt: new Date(row.createdAt).toISOString(),
|
||||
updatedAt: new Date(row.updatedAt).toISOString(),
|
||||
completedAt: row.completedAt ? new Date(row.completedAt).toISOString() : undefined,
|
||||
completedAt: row.completedAt
|
||||
? new Date(row.completedAt).toISOString()
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private async deleteTodoDB(id: string): Promise<void> {
|
||||
const rows = await query("DELETE FROM todo_items WHERE id = $1 RETURNING id", [id]);
|
||||
const rows = await query(
|
||||
"DELETE FROM todo_items WHERE id = $1 RETURNING id",
|
||||
[id]
|
||||
);
|
||||
if (rows.length === 0) {
|
||||
throw new Error(`To-Do 항목을 찾을 수 없습니다: ${id}`);
|
||||
}
|
||||
|
|
@ -443,7 +466,10 @@ export class TodoService {
|
|||
inProgress: todos.filter((t) => t.status === "in_progress").length,
|
||||
completed: todos.filter((t) => t.status === "completed").length,
|
||||
urgent: todos.filter((t) => t.isUrgent).length,
|
||||
overdue: todos.filter((t) => t.dueDate && new Date(t.dueDate) < now && t.status !== "completed").length,
|
||||
overdue: todos.filter(
|
||||
(t) =>
|
||||
t.dueDate && new Date(t.dueDate) < now && t.status !== "completed"
|
||||
).length,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,14 +34,11 @@ COPY --from=build /app/dist ./dist
|
|||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Create logs, uploads, and data directories and set permissions (use existing node user with UID 1000)
|
||||
RUN mkdir -p logs \
|
||||
uploads/mail-attachments \
|
||||
uploads/mail-templates \
|
||||
uploads/mail-accounts \
|
||||
data/mail-sent && \
|
||||
chown -R node:node logs uploads data && \
|
||||
chmod -R 755 logs uploads data
|
||||
# 루트 디렉토리만 생성하고 node 유저에게 쓰기 권한 부여
|
||||
# 하위 디렉토리는 애플리케이션이 런타임에 자동 생성
|
||||
RUN mkdir -p logs uploads data && \
|
||||
chown -R node:node /app && \
|
||||
chmod -R 755 /app
|
||||
|
||||
EXPOSE 3001
|
||||
USER node
|
||||
|
|
|
|||
|
|
@ -20,8 +20,8 @@ services:
|
|||
LOG_LEVEL: info
|
||||
ENCRYPTION_KEY: ilshin-plm-mail-encryption-key-32characters-2024-secure
|
||||
volumes:
|
||||
- /home/vexplor/backend_data/uploads:/app/uploads
|
||||
- /home/vexplor/backend_data/data:/app/data
|
||||
- backend_uploads:/app/uploads
|
||||
- backend_data:/app/data
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.backend.rule=Host(`api.vexplor.com`)
|
||||
|
|
@ -46,7 +46,7 @@ services:
|
|||
PORT: "3000"
|
||||
HOSTNAME: 0.0.0.0
|
||||
volumes:
|
||||
- /home/vexplor/frontend_data:/app/data
|
||||
- frontend_data:/app/data
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.frontend.rule=Host(`v1.vexplor.com`)
|
||||
|
|
@ -55,6 +55,14 @@ services:
|
|||
- traefik.http.routers.frontend.tls.certresolver=le
|
||||
- traefik.http.services.frontend.loadbalancer.server.port=3000
|
||||
|
||||
volumes:
|
||||
backend_uploads:
|
||||
driver: local
|
||||
backend_data:
|
||||
driver: local
|
||||
frontend_data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
default:
|
||||
name: toktork_server_default
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ services:
|
|||
- LOG_LEVEL=debug
|
||||
- ENCRYPTION_KEY=ilshin-plm-mail-encryption-key-32characters-2024-secure
|
||||
- KMA_API_KEY=ogdXr2e9T4iHV69nvV-IwA
|
||||
- ITS_API_KEY=${ITS_API_KEY:-}
|
||||
- ITS_API_KEY=d6b9befec3114d648284674b8fddcc32
|
||||
- EXPRESSWAY_API_KEY=${EXPRESSWAY_API_KEY:-}
|
||||
volumes:
|
||||
- ../../backend-node:/app # 개발 모드: 코드 변경 시 자동 반영
|
||||
|
|
|
|||
|
|
@ -37,8 +37,11 @@ COPY --from=build /app/dist ./dist
|
|||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Create logs and uploads directories and set permissions
|
||||
RUN mkdir -p logs uploads && chown -R appuser:appgroup logs uploads && chmod -R 755 logs uploads
|
||||
# 루트 디렉토리만 생성하고 appuser에게 쓰기 권한 부여
|
||||
# 하위 디렉토리는 애플리케이션이 런타임에 자동 생성
|
||||
RUN mkdir -p logs uploads data && \
|
||||
chown -R appuser:appgroup /app && \
|
||||
chmod -R 755 /app
|
||||
|
||||
EXPOSE 8080
|
||||
USER appuser
|
||||
|
|
|
|||
|
|
@ -220,13 +220,7 @@ export function DashboardSidebar() {
|
|||
subtype="booking-alert"
|
||||
onDragStart={handleDragStart}
|
||||
/>
|
||||
<DraggableItem
|
||||
icon="🔧"
|
||||
title="정비 일정 관리"
|
||||
type="widget"
|
||||
subtype="maintenance"
|
||||
onDragStart={handleDragStart}
|
||||
/>
|
||||
{/* 정비 일정 관리 위젯 제거 - 커스텀 목록 카드로 대체 가능 */}
|
||||
<DraggableItem
|
||||
icon="📂"
|
||||
title="문서 다운로드"
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
|
|||
const [chartConfig, setChartConfig] = useState<ChartConfig>(element.chartConfig || {});
|
||||
const [queryResult, setQueryResult] = useState<QueryResult | null>(null);
|
||||
const [currentStep, setCurrentStep] = useState<1 | 2>(1);
|
||||
const [customTitle, setCustomTitle] = useState<string>(element.customTitle || "");
|
||||
|
||||
// 차트 설정이 필요 없는 위젯 (쿼리/API만 필요)
|
||||
const isSimpleWidget =
|
||||
|
|
@ -56,6 +57,7 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
|
|||
setChartConfig(element.chartConfig || {});
|
||||
setQueryResult(null);
|
||||
setCurrentStep(1);
|
||||
setCustomTitle(element.customTitle || "");
|
||||
}
|
||||
}, [isOpen, element]);
|
||||
|
||||
|
|
@ -119,13 +121,14 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
|
|||
...element,
|
||||
dataSource,
|
||||
chartConfig,
|
||||
customTitle: customTitle.trim() || undefined, // 빈 문자열이면 undefined
|
||||
};
|
||||
|
||||
console.log(" 저장할 element:", updatedElement);
|
||||
|
||||
onSave(updatedElement);
|
||||
onClose();
|
||||
}, [element, dataSource, chartConfig, onSave, onClose]);
|
||||
}, [element, dataSource, chartConfig, customTitle, onSave, onClose]);
|
||||
|
||||
// 모달이 열려있지 않으면 렌더링하지 않음
|
||||
if (!isOpen) return null;
|
||||
|
|
@ -147,28 +150,32 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
|
|||
chartConfig.yAxis &&
|
||||
(typeof chartConfig.yAxis === "string" || (Array.isArray(chartConfig.yAxis) && chartConfig.yAxis.length > 0));
|
||||
|
||||
const canSave = isSimpleWidget
|
||||
? // 간단한 위젯: 2단계에서 쿼리 테스트 후 저장 가능
|
||||
currentStep === 2 && queryResult && queryResult.rows.length > 0
|
||||
: isMapWidget
|
||||
? // 지도 위젯: 위도/경도 매핑 필요
|
||||
currentStep === 2 &&
|
||||
queryResult &&
|
||||
queryResult.rows.length > 0 &&
|
||||
chartConfig.latitudeColumn &&
|
||||
chartConfig.longitudeColumn
|
||||
: // 차트: 기존 로직 (2단계에서 차트 설정 필요)
|
||||
currentStep === 2 &&
|
||||
queryResult &&
|
||||
queryResult.rows.length > 0 &&
|
||||
chartConfig.xAxis &&
|
||||
(isPieChart || isApiSource
|
||||
? // 파이/도넛 차트 또는 REST API
|
||||
chartConfig.aggregation === "count"
|
||||
? true // count는 Y축 없어도 됨
|
||||
: hasYAxis // 다른 집계(sum, avg, max, min) 또는 집계 없음 → Y축 필수
|
||||
: // 일반 차트 (DB): Y축 필수
|
||||
hasYAxis);
|
||||
// customTitle이 변경되었는지 확인
|
||||
const isTitleChanged = customTitle.trim() !== (element.customTitle || "");
|
||||
|
||||
const canSave = isTitleChanged || // 제목만 변경해도 저장 가능
|
||||
(isSimpleWidget
|
||||
? // 간단한 위젯: 2단계에서 쿼리 테스트 후 저장 가능
|
||||
currentStep === 2 && queryResult && queryResult.rows.length > 0
|
||||
: isMapWidget
|
||||
? // 지도 위젯: 위도/경도 매핑 필요
|
||||
currentStep === 2 &&
|
||||
queryResult &&
|
||||
queryResult.rows.length > 0 &&
|
||||
chartConfig.latitudeColumn &&
|
||||
chartConfig.longitudeColumn
|
||||
: // 차트: 기존 로직 (2단계에서 차트 설정 필요)
|
||||
currentStep === 2 &&
|
||||
queryResult &&
|
||||
queryResult.rows.length > 0 &&
|
||||
chartConfig.xAxis &&
|
||||
(isPieChart || isApiSource
|
||||
? // 파이/도넛 차트 또는 REST API
|
||||
chartConfig.aggregation === "count"
|
||||
? true // count는 Y축 없어도 됨
|
||||
: hasYAxis // 다른 집계(sum, avg, max, min) 또는 집계 없음 → Y축 필수
|
||||
: // 일반 차트 (DB): Y축 필수
|
||||
hasYAxis));
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
|
|
@ -178,20 +185,39 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
|
|||
}`}
|
||||
>
|
||||
{/* 모달 헤더 */}
|
||||
<div className="flex items-center justify-between border-b p-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900">{element.title} 설정</h2>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
{isSimpleWidget
|
||||
? "데이터 소스를 설정하세요"
|
||||
: currentStep === 1
|
||||
? "데이터 소스를 선택하세요"
|
||||
: "쿼리를 실행하고 차트를 설정하세요"}
|
||||
<div className="border-b p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<h2 className="text-xl font-semibold text-gray-900">{element.title} 설정</h2>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
{isSimpleWidget
|
||||
? "데이터 소스를 설정하세요"
|
||||
: currentStep === 1
|
||||
? "데이터 소스를 선택하세요"
|
||||
: "쿼리를 실행하고 차트를 설정하세요"}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" onClick={onClose} className="h-8 w-8">
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 커스텀 제목 입력 */}
|
||||
<div className="mt-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
위젯 제목 (선택사항)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={customTitle}
|
||||
onChange={(e) => setCustomTitle(e.target.value)}
|
||||
placeholder={`예: 정비 일정 목록, 창고 위치 현황 등 (비워두면 자동 생성)`}
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
💡 비워두면 테이블명으로 자동 생성됩니다 (예: "maintenance_schedules 목록")
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" onClick={onClose} className="h-8 w-8">
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 진행 상황 표시 - 간단한 위젯은 표시 안 함 */}
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ export interface DashboardElement {
|
|||
position: Position;
|
||||
size: Size;
|
||||
title: string;
|
||||
customTitle?: string; // 사용자 정의 제목 (옵션)
|
||||
content: string;
|
||||
dataSource?: ChartDataSource; // 데이터 소스 설정
|
||||
chartConfig?: ChartConfig; // 차트 설정
|
||||
|
|
|
|||
|
|
@ -3,6 +3,109 @@
|
|||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { DashboardElement, QueryResult } from "@/components/admin/dashboard/types";
|
||||
import { ChartRenderer } from "@/components/admin/dashboard/charts/ChartRenderer";
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
// 위젯 동적 import - 모든 위젯
|
||||
const ListSummaryWidget = dynamic(() => import("./widgets/ListSummaryWidget"), { ssr: false });
|
||||
const MapSummaryWidget = dynamic(() => import("./widgets/MapSummaryWidget"), { ssr: false });
|
||||
const StatusSummaryWidget = dynamic(() => import("./widgets/StatusSummaryWidget"), { ssr: false });
|
||||
const RiskAlertWidget = dynamic(() => import("./widgets/RiskAlertWidget"), { ssr: false });
|
||||
const WeatherWidget = dynamic(() => import("./widgets/WeatherWidget"), { ssr: false });
|
||||
const ExchangeWidget = dynamic(() => import("./widgets/ExchangeWidget"), { ssr: false });
|
||||
const VehicleStatusWidget = dynamic(() => import("./widgets/VehicleStatusWidget"), { ssr: false });
|
||||
const VehicleListWidget = dynamic(() => import("./widgets/VehicleListWidget"), { ssr: false });
|
||||
const VehicleMapOnlyWidget = dynamic(() => import("./widgets/VehicleMapOnlyWidget"), { ssr: false });
|
||||
const CargoListWidget = dynamic(() => import("./widgets/CargoListWidget"), { ssr: false });
|
||||
const CustomerIssuesWidget = dynamic(() => import("./widgets/CustomerIssuesWidget"), { ssr: false });
|
||||
const DeliveryStatusWidget = dynamic(() => import("./widgets/DeliveryStatusWidget"), { ssr: false });
|
||||
const DeliveryStatusSummaryWidget = dynamic(() => import("./widgets/DeliveryStatusSummaryWidget"), { ssr: false });
|
||||
const DeliveryTodayStatsWidget = dynamic(() => import("./widgets/DeliveryTodayStatsWidget"), { ssr: false });
|
||||
const TodoWidget = dynamic(() => import("./widgets/TodoWidget"), { ssr: false });
|
||||
const DocumentWidget = dynamic(() => import("./widgets/DocumentWidget"), { ssr: false });
|
||||
const BookingAlertWidget = dynamic(() => import("./widgets/BookingAlertWidget"), { ssr: false });
|
||||
const MaintenanceWidget = dynamic(() => import("./widgets/MaintenanceWidget"), { ssr: false });
|
||||
const CalculatorWidget = dynamic(() => import("./widgets/CalculatorWidget"), { ssr: false });
|
||||
const CalendarWidget = dynamic(() => import("@/components/admin/dashboard/widgets/CalendarWidget").then(mod => ({ default: mod.CalendarWidget })), { ssr: false });
|
||||
|
||||
/**
|
||||
* 위젯 렌더링 함수 - DashboardSidebar의 모든 subtype 처리
|
||||
* ViewerElement에서 사용하기 위해 컴포넌트 외부에 정의
|
||||
*/
|
||||
function renderWidget(element: DashboardElement) {
|
||||
switch (element.subtype) {
|
||||
// 차트는 ChartRenderer에서 처리됨 (이 함수 호출 안됨)
|
||||
|
||||
// === 위젯 종류 ===
|
||||
case "exchange":
|
||||
return <ExchangeWidget element={element} />;
|
||||
case "weather":
|
||||
return <WeatherWidget element={element} />;
|
||||
case "calculator":
|
||||
return <CalculatorWidget element={element} />;
|
||||
case "clock":
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center bg-gradient-to-br from-blue-400 to-purple-600 p-4 text-white">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-3xl">⏰</div>
|
||||
<div className="text-sm">시계 위젯 (개발 예정)</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case "map-summary":
|
||||
return <MapSummaryWidget element={element} />;
|
||||
case "list-summary":
|
||||
return <ListSummaryWidget element={element} />;
|
||||
case "risk-alert":
|
||||
return <RiskAlertWidget element={element} />;
|
||||
case "calendar":
|
||||
return <CalendarWidget element={element} />;
|
||||
case "status-summary":
|
||||
return <StatusSummaryWidget element={element} />;
|
||||
|
||||
// === 운영/작업 지원 ===
|
||||
case "todo":
|
||||
return <TodoWidget element={element} />;
|
||||
case "booking-alert":
|
||||
return <BookingAlertWidget element={element} />;
|
||||
case "maintenance":
|
||||
return <MaintenanceWidget element={element} />;
|
||||
case "document":
|
||||
return <DocumentWidget element={element} />;
|
||||
case "list":
|
||||
return <ListSummaryWidget element={element} />;
|
||||
|
||||
// === 차량 관련 (추가 위젯) ===
|
||||
case "vehicle-status":
|
||||
return <VehicleStatusWidget />;
|
||||
case "vehicle-list":
|
||||
return <VehicleListWidget />;
|
||||
case "vehicle-map":
|
||||
return <VehicleMapOnlyWidget element={element} />;
|
||||
|
||||
// === 배송 관련 (추가 위젯) ===
|
||||
case "delivery-status":
|
||||
return <DeliveryStatusWidget />;
|
||||
case "delivery-status-summary":
|
||||
return <DeliveryStatusSummaryWidget />;
|
||||
case "delivery-today-stats":
|
||||
return <DeliveryTodayStatsWidget />;
|
||||
case "cargo-list":
|
||||
return <CargoListWidget />;
|
||||
case "customer-issues":
|
||||
return <CustomerIssuesWidget />;
|
||||
|
||||
// === 기본 fallback ===
|
||||
default:
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center bg-gradient-to-br from-gray-400 to-gray-600 p-4 text-white">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-3xl">❓</div>
|
||||
<div className="text-sm">알 수 없는 위젯 타입: {element.subtype}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
interface DashboardViewerProps {
|
||||
elements: DashboardElement[];
|
||||
|
|
@ -198,18 +301,7 @@ function ViewerElement({ element, data, isLoading, onRefresh }: ViewerElementPro
|
|||
<div className="h-[calc(100%-57px)]">
|
||||
{element.type === "chart" ? (
|
||||
<ChartRenderer element={element} data={data} width={element.size.width} height={element.size.height - 57} />
|
||||
) : (
|
||||
// 위젯 렌더링
|
||||
<div className="flex h-full w-full items-center justify-center bg-gradient-to-br from-blue-400 to-purple-600 p-4 text-white">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-3xl">
|
||||
{element.subtype === "exchange" && "💱"}
|
||||
{element.subtype === "weather" && "☁️"}
|
||||
</div>
|
||||
<div className="text-sm whitespace-pre-line">{element.content}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
) : renderWidget(element)}
|
||||
</div>
|
||||
|
||||
{/* 로딩 오버레이 */}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Check, X, Phone, MapPin, Package, Clock, AlertCircle } from "lucide-react";
|
||||
import { DashboardElement } from "@/components/admin/dashboard/types";
|
||||
|
||||
interface BookingRequest {
|
||||
id: string;
|
||||
|
|
@ -19,7 +20,11 @@ interface BookingRequest {
|
|||
estimatedCost?: number;
|
||||
}
|
||||
|
||||
export default function BookingAlertWidget() {
|
||||
interface BookingAlertWidgetProps {
|
||||
element?: DashboardElement;
|
||||
}
|
||||
|
||||
export default function BookingAlertWidget({ element }: BookingAlertWidgetProps) {
|
||||
const [bookings, setBookings] = useState<BookingRequest[]>([]);
|
||||
const [newCount, setNewCount] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
|
@ -156,7 +161,7 @@ export default function BookingAlertWidget() {
|
|||
<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">🔔 예약 요청 알림</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}
|
||||
|
|
|
|||
|
|
@ -9,12 +9,14 @@
|
|||
|
||||
import React, { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { DashboardElement } from '@/components/admin/dashboard/types';
|
||||
|
||||
interface CalculatorWidgetProps {
|
||||
element?: DashboardElement;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function CalculatorWidget({ className = '' }: CalculatorWidgetProps) {
|
||||
export default function CalculatorWidget({ element, className = '' }: CalculatorWidgetProps) {
|
||||
const [display, setDisplay] = useState<string>('0');
|
||||
const [previousValue, setPreviousValue] = useState<number | null>(null);
|
||||
const [operation, setOperation] = useState<string | null>(null);
|
||||
|
|
@ -117,7 +119,10 @@ export default function CalculatorWidget({ className = '' }: CalculatorWidgetPro
|
|||
|
||||
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 justify-center gap-2">
|
||||
<div className="h-full flex flex-col gap-2">
|
||||
{/* 제목 */}
|
||||
<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]">
|
||||
<div className="text-right h-full flex flex-col justify-center">
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import React, { useState } from "react";
|
||||
import { FileText, Download, Calendar, Folder, Search } from "lucide-react";
|
||||
import { DashboardElement } from "@/components/admin/dashboard/types";
|
||||
|
||||
interface Document {
|
||||
id: string;
|
||||
|
|
@ -13,64 +14,69 @@ interface Document {
|
|||
description?: string;
|
||||
}
|
||||
|
||||
// 목 데이터
|
||||
const mockDocuments: Document[] = [
|
||||
{
|
||||
id: "1",
|
||||
name: "2025년 1월 세금계산서.pdf",
|
||||
category: "세금계산서",
|
||||
size: "1.2 MB",
|
||||
uploadDate: "2025-01-05",
|
||||
url: "/documents/tax-invoice-202501.pdf",
|
||||
description: "1월 매출 세금계산서",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "차량보험증권_서울12가3456.pdf",
|
||||
category: "보험",
|
||||
size: "856 KB",
|
||||
uploadDate: "2024-12-20",
|
||||
url: "/documents/insurance-vehicle-1.pdf",
|
||||
description: "1톤 트럭 종합보험",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
name: "운송계약서_ABC물류.pdf",
|
||||
category: "계약서",
|
||||
size: "2.4 MB",
|
||||
uploadDate: "2024-12-15",
|
||||
url: "/documents/contract-abc-logistics.pdf",
|
||||
description: "ABC물류 연간 운송 계약",
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
name: "2024년 12월 세금계산서.pdf",
|
||||
category: "세금계산서",
|
||||
size: "1.1 MB",
|
||||
uploadDate: "2024-12-05",
|
||||
url: "/documents/tax-invoice-202412.pdf",
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
name: "화물배상책임보험증권.pdf",
|
||||
category: "보험",
|
||||
size: "720 KB",
|
||||
uploadDate: "2024-11-30",
|
||||
url: "/documents/cargo-insurance.pdf",
|
||||
description: "화물 배상책임보험",
|
||||
},
|
||||
{
|
||||
id: "6",
|
||||
name: "차고지 임대계약서.pdf",
|
||||
category: "계약서",
|
||||
size: "1.8 MB",
|
||||
uploadDate: "2024-11-15",
|
||||
url: "/documents/garage-lease-contract.pdf",
|
||||
},
|
||||
];
|
||||
// 목 데이터 (하드코딩 - 주석처리됨)
|
||||
// const mockDocuments: Document[] = [
|
||||
// {
|
||||
// id: "1",
|
||||
// name: "2025년 1월 세금계산서.pdf",
|
||||
// category: "세금계산서",
|
||||
// size: "1.2 MB",
|
||||
// uploadDate: "2025-01-05",
|
||||
// url: "/documents/tax-invoice-202501.pdf",
|
||||
// description: "1월 매출 세금계산서",
|
||||
// },
|
||||
// {
|
||||
// id: "2",
|
||||
// name: "차량보험증권_서울12가3456.pdf",
|
||||
// category: "보험",
|
||||
// size: "856 KB",
|
||||
// uploadDate: "2024-12-20",
|
||||
// url: "/documents/insurance-vehicle-1.pdf",
|
||||
// description: "1톤 트럭 종합보험",
|
||||
// },
|
||||
// {
|
||||
// id: "3",
|
||||
// name: "운송계약서_ABC물류.pdf",
|
||||
// category: "계약서",
|
||||
// size: "2.4 MB",
|
||||
// uploadDate: "2024-12-15",
|
||||
// url: "/documents/contract-abc-logistics.pdf",
|
||||
// description: "ABC물류 연간 운송 계약",
|
||||
// },
|
||||
// {
|
||||
// id: "4",
|
||||
// name: "2024년 12월 세금계산서.pdf",
|
||||
// category: "세금계산서",
|
||||
// size: "1.1 MB",
|
||||
// uploadDate: "2024-12-05",
|
||||
// url: "/documents/tax-invoice-202412.pdf",
|
||||
// },
|
||||
// {
|
||||
// id: "5",
|
||||
// name: "화물배상책임보험증권.pdf",
|
||||
// category: "보험",
|
||||
// size: "720 KB",
|
||||
// uploadDate: "2024-11-30",
|
||||
// url: "/documents/cargo-insurance.pdf",
|
||||
// description: "화물 배상책임보험",
|
||||
// },
|
||||
// {
|
||||
// id: "6",
|
||||
// name: "차고지 임대계약서.pdf",
|
||||
// category: "계약서",
|
||||
// size: "1.8 MB",
|
||||
// uploadDate: "2024-11-15",
|
||||
// url: "/documents/garage-lease-contract.pdf",
|
||||
// },
|
||||
// ];
|
||||
|
||||
export default function DocumentWidget() {
|
||||
const [documents] = useState<Document[]>(mockDocuments);
|
||||
interface DocumentWidgetProps {
|
||||
element?: DashboardElement;
|
||||
}
|
||||
|
||||
export default function DocumentWidget({ element }: DocumentWidgetProps) {
|
||||
// TODO: 실제 API 연동 필요
|
||||
const [documents] = useState<Document[]>([]);
|
||||
const [filter, setFilter] = useState<"all" | Document["category"]>("all");
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
|
||||
|
|
@ -126,7 +132,7 @@ export default function DocumentWidget() {
|
|||
{/* 헤더 */}
|
||||
<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">📂 문서 관리</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>
|
||||
|
|
|
|||
|
|
@ -12,14 +12,17 @@ import { TrendingUp, TrendingDown, RefreshCw, ArrowRightLeft } from 'lucide-reac
|
|||
import { Button } from '@/components/ui/button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DashboardElement } from '@/components/admin/dashboard/types';
|
||||
|
||||
interface ExchangeWidgetProps {
|
||||
element?: DashboardElement;
|
||||
baseCurrency?: string;
|
||||
targetCurrency?: string;
|
||||
refreshInterval?: number; // 새로고침 간격 (ms), 기본값: 600000 (10분)
|
||||
}
|
||||
|
||||
export default function ExchangeWidget({
|
||||
element,
|
||||
baseCurrency = 'KRW',
|
||||
targetCurrency = 'USD',
|
||||
refreshInterval = 600000,
|
||||
|
|
@ -136,7 +139,7 @@ export default function ExchangeWidget({
|
|||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-base font-semibold text-gray-900 mb-1">💱 환율</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', {
|
||||
|
|
|
|||
|
|
@ -14,51 +14,52 @@ interface MaintenanceSchedule {
|
|||
estimatedCost?: number;
|
||||
}
|
||||
|
||||
// 목 데이터
|
||||
const mockSchedules: MaintenanceSchedule[] = [
|
||||
{
|
||||
id: "1",
|
||||
vehicleNumber: "서울12가3456",
|
||||
vehicleType: "1톤 트럭",
|
||||
maintenanceType: "정기점검",
|
||||
scheduledDate: new Date(Date.now() + 2 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
status: "scheduled",
|
||||
notes: "6개월 정기점검",
|
||||
estimatedCost: 300000,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
vehicleNumber: "경기34나5678",
|
||||
vehicleType: "2.5톤 트럭",
|
||||
maintenanceType: "오일교환",
|
||||
scheduledDate: new Date(Date.now() + 1 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
status: "scheduled",
|
||||
estimatedCost: 150000,
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
vehicleNumber: "인천56다7890",
|
||||
vehicleType: "라보",
|
||||
maintenanceType: "타이어교체",
|
||||
scheduledDate: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
status: "overdue",
|
||||
notes: "긴급",
|
||||
estimatedCost: 400000,
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
vehicleNumber: "부산78라1234",
|
||||
vehicleType: "1톤 트럭",
|
||||
maintenanceType: "수리",
|
||||
scheduledDate: new Date().toISOString(),
|
||||
status: "in_progress",
|
||||
notes: "엔진 점검 중",
|
||||
estimatedCost: 800000,
|
||||
},
|
||||
];
|
||||
// 목 데이터 (하드코딩 - 주석처리됨)
|
||||
// const mockSchedules: MaintenanceSchedule[] = [
|
||||
// {
|
||||
// id: "1",
|
||||
// vehicleNumber: "서울12가3456",
|
||||
// vehicleType: "1톤 트럭",
|
||||
// maintenanceType: "정기점검",
|
||||
// scheduledDate: new Date(Date.now() + 2 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
// status: "scheduled",
|
||||
// notes: "6개월 정기점검",
|
||||
// estimatedCost: 300000,
|
||||
// },
|
||||
// {
|
||||
// id: "2",
|
||||
// vehicleNumber: "경기34나5678",
|
||||
// vehicleType: "2.5톤 트럭",
|
||||
// maintenanceType: "오일교환",
|
||||
// scheduledDate: new Date(Date.now() + 1 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
// status: "scheduled",
|
||||
// estimatedCost: 150000,
|
||||
// },
|
||||
// {
|
||||
// id: "3",
|
||||
// vehicleNumber: "인천56다7890",
|
||||
// vehicleType: "라보",
|
||||
// maintenanceType: "타이어교체",
|
||||
// scheduledDate: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
// status: "overdue",
|
||||
// notes: "긴급",
|
||||
// estimatedCost: 400000,
|
||||
// },
|
||||
// {
|
||||
// id: "4",
|
||||
// vehicleNumber: "부산78라1234",
|
||||
// vehicleType: "1톤 트럭",
|
||||
// maintenanceType: "수리",
|
||||
// scheduledDate: new Date().toISOString(),
|
||||
// status: "in_progress",
|
||||
// notes: "엔진 점검 중",
|
||||
// estimatedCost: 800000,
|
||||
// },
|
||||
// ];
|
||||
|
||||
export default function MaintenanceWidget() {
|
||||
const [schedules] = useState<MaintenanceSchedule[]>(mockSchedules);
|
||||
// TODO: 실제 API 연동 필요
|
||||
const [schedules] = useState<MaintenanceSchedule[]>([]);
|
||||
const [filter, setFilter] = useState<"all" | MaintenanceSchedule["status"]>("all");
|
||||
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
|
||||
|
||||
|
|
|
|||
|
|
@ -150,7 +150,8 @@ export default function MapSummaryWidget({ element }: MapSummaryWidgetProps) {
|
|||
}
|
||||
};
|
||||
|
||||
const displayTitle = tableName ? `${translateTableName(tableName)} 위치` : "위치 지도";
|
||||
// customTitle이 있으면 사용, 없으면 테이블명으로 자동 생성
|
||||
const displayTitle = element.customTitle || (tableName ? `${translateTableName(tableName)} 위치` : "위치 지도");
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-hidden bg-gradient-to-br from-slate-50 to-blue-50 p-2">
|
||||
|
|
@ -181,13 +182,15 @@ export default function MapSummaryWidget({ element }: MapSummaryWidgetProps) {
|
|||
)}
|
||||
|
||||
{/* 지도 (항상 표시) */}
|
||||
<div className="flex-1 rounded border border-gray-300 bg-white overflow-hidden">
|
||||
<div className="relative flex-1 rounded border border-gray-300 bg-white overflow-hidden z-0">
|
||||
<MapContainer
|
||||
key={`map-${element.id}`}
|
||||
center={[36.5, 127.5]}
|
||||
zoom={7}
|
||||
style={{ height: "100%", width: "100%" }}
|
||||
style={{ height: "100%", width: "100%", zIndex: 0 }}
|
||||
zoomControl={true}
|
||||
preferCanvas={true}
|
||||
className="z-0"
|
||||
>
|
||||
{/* 브이월드 타일맵 */}
|
||||
<TileLayer
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
|
|||
import { Badge } from "@/components/ui/badge";
|
||||
import { RefreshCw, AlertTriangle, Cloud, Construction } from "lucide-react";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { DashboardElement } from "@/components/admin/dashboard/types";
|
||||
|
||||
// 알림 타입
|
||||
type AlertType = "accident" | "weather" | "construction";
|
||||
|
|
@ -21,7 +22,11 @@ interface Alert {
|
|||
timestamp: string;
|
||||
}
|
||||
|
||||
export default function RiskAlertWidget() {
|
||||
interface RiskAlertWidgetProps {
|
||||
element?: DashboardElement;
|
||||
}
|
||||
|
||||
export default function RiskAlertWidget({ element }: RiskAlertWidgetProps) {
|
||||
const [alerts, setAlerts] = useState<Alert[]>([]);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [filter, setFilter] = useState<AlertType | "all">("all");
|
||||
|
|
@ -163,7 +168,7 @@ export default function RiskAlertWidget() {
|
|||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-5 w-5 text-red-600" />
|
||||
<h3 className="text-base font-semibold text-gray-900">리스크 / 알림</h3>
|
||||
<h3 className="text-base font-semibold text-gray-900">{element?.customTitle || "리스크 / 알림"}</h3>
|
||||
{stats.high > 0 && (
|
||||
<Badge className="bg-red-100 text-red-700 hover:bg-red-100">긴급 {stats.high}건</Badge>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -349,7 +349,8 @@ export default function StatusSummaryWidget({
|
|||
return name;
|
||||
};
|
||||
|
||||
const displayTitle = tableName ? `${translateTableName(tableName)} 현황` : title;
|
||||
// customTitle이 있으면 사용, 없으면 테이블명으로 자동 생성
|
||||
const displayTitle = element.customTitle || (tableName ? `${translateTableName(tableName)} 현황` : title);
|
||||
|
||||
return (
|
||||
<div className={`flex h-full w-full flex-col overflow-hidden bg-gradient-to-br ${bgGradient} p-2`}>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Plus, Check, X, Clock, AlertCircle, GripVertical, ChevronDown } from "lucide-react";
|
||||
import { DashboardElement } from "@/components/admin/dashboard/types";
|
||||
|
||||
interface TodoItem {
|
||||
id: string;
|
||||
|
|
@ -27,7 +28,11 @@ interface TodoStats {
|
|||
overdue: number;
|
||||
}
|
||||
|
||||
export default function TodoWidget() {
|
||||
interface TodoWidgetProps {
|
||||
element?: DashboardElement;
|
||||
}
|
||||
|
||||
export default function TodoWidget({ element }: TodoWidgetProps) {
|
||||
const [todos, setTodos] = useState<TodoItem[]>([]);
|
||||
const [stats, setStats] = useState<TodoStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
|
@ -193,7 +198,7 @@ export default function TodoWidget() {
|
|||
{/* 헤더 */}
|
||||
<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">✅ To-Do / 긴급 지시</h3>
|
||||
<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"
|
||||
|
|
|
|||
|
|
@ -172,13 +172,15 @@ export default function VehicleMapOnlyWidget({ element, refreshInterval = 30000
|
|||
|
||||
{/* 지도 영역 - 브이월드 타일맵 */}
|
||||
<div className="h-[calc(100%-60px)]">
|
||||
<div className="relative h-full overflow-hidden rounded-lg border-2 border-gray-300 bg-white">
|
||||
<div className="relative h-full overflow-hidden rounded-lg border-2 border-gray-300 bg-white z-0">
|
||||
<MapContainer
|
||||
key={`vehicle-map-${element.id}`}
|
||||
center={[36.5, 127.5]}
|
||||
zoom={7}
|
||||
style={{ height: "100%", width: "100%" }}
|
||||
style={{ height: "100%", width: "100%", zIndex: 0 }}
|
||||
zoomControl={true}
|
||||
preferCanvas={true}
|
||||
className="z-0"
|
||||
>
|
||||
{/* 브이월드 타일맵 (HTTPS, 캐싱 적용) */}
|
||||
<TileLayer
|
||||
|
|
|
|||
|
|
@ -24,13 +24,16 @@ import { Button } from '@/components/ui/button';
|
|||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { DashboardElement } from '@/components/admin/dashboard/types';
|
||||
|
||||
interface WeatherWidgetProps {
|
||||
element?: DashboardElement;
|
||||
city?: string;
|
||||
refreshInterval?: number; // 새로고침 간격 (ms), 기본값: 600000 (10분)
|
||||
}
|
||||
|
||||
export default function WeatherWidget({
|
||||
element,
|
||||
city = '서울',
|
||||
refreshInterval = 600000,
|
||||
}: WeatherWidgetProps) {
|
||||
|
|
@ -309,6 +312,7 @@ export default function WeatherWidget({
|
|||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-1">🌤️ {element?.customTitle || "날씨"}</h3>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
|
|
@ -316,10 +320,10 @@ export default function WeatherWidget({
|
|||
variant="ghost"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="justify-between text-lg font-semibold text-gray-900 hover:bg-white/50 h-auto py-1 px-2"
|
||||
className="justify-between text-sm text-gray-600 hover:bg-white/50 h-auto py-0.5 px-2"
|
||||
>
|
||||
{cities.find((city) => city.value === selectedCity)?.label || '도시 선택'}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
<ChevronsUpDown className="ml-2 h-3.5 w-3.5 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-0" align="start">
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
import { DashboardElement } from "@/components/admin/dashboard/types";
|
||||
|
||||
// API 기본 설정
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001/api";
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "/api";
|
||||
|
||||
// 토큰 가져오기 (실제 인증 시스템에 맞게 수정)
|
||||
function getAuthToken(): string | null {
|
||||
|
|
|
|||
|
|
@ -20,17 +20,10 @@ echo ""
|
|||
echo "[1/6] Git 최신 코드 가져오기..."
|
||||
git pull origin main
|
||||
|
||||
# 호스트 디렉토리 준비
|
||||
# Docker 볼륨 사용으로 호스트 디렉토리 준비 불필요
|
||||
echo ""
|
||||
echo "[2/6] 호스트 디렉토리 준비..."
|
||||
mkdir -p /home/vexplor/backend_data/data/mail-sent
|
||||
mkdir -p /home/vexplor/backend_data/uploads/mail-attachments
|
||||
mkdir -p /home/vexplor/backend_data/uploads/mail-templates
|
||||
mkdir -p /home/vexplor/backend_data/uploads/mail-accounts
|
||||
mkdir -p /home/vexplor/frontend_data
|
||||
chmod -R 755 /home/vexplor/backend_data
|
||||
chmod -R 755 /home/vexplor/frontend_data
|
||||
echo "디렉토리 생성 완료 (mail-sent, mail-attachments, mail-templates, mail-accounts, frontend)"
|
||||
echo "[2/6] Docker 볼륨 확인..."
|
||||
echo "Docker named volumes 사용 (권한 문제 없음)"
|
||||
|
||||
# 기존 컨테이너 중지 및 제거
|
||||
echo ""
|
||||
|
|
|
|||
Loading…
Reference in New Issue