달력 및 기사 관리 위젯 구현 #97

Merged
hyeonsu merged 8 commits from feature/dashboard into main 2025-10-14 13:22:19 +09:00
11 changed files with 1365 additions and 7 deletions
Showing only changes of commit 0d4b985d5a - Show all commits

View File

@ -26,6 +26,8 @@ const CalculatorWidget = dynamic(() => import("@/components/dashboard/widgets/Ca
import { ClockWidget } from "./widgets/ClockWidget";
// 달력 위젯 임포트
import { CalendarWidget } from "./widgets/CalendarWidget";
// 기사 관리 위젯 임포트
import { DriverManagementWidget } from "./widgets/DriverManagementWidget";
interface CanvasElementProps {
element: DashboardElement;
@ -294,6 +296,8 @@ export function CanvasElement({
return "bg-gradient-to-br from-teal-400 to-cyan-600";
case "calendar":
return "bg-gradient-to-br from-indigo-400 to-purple-600";
case "driver-management":
return "bg-gradient-to-br from-blue-400 to-indigo-600";
default:
return "bg-gray-200";
}
@ -323,9 +327,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.title}</span>
<div className="flex gap-1">
{/* 설정 버튼 (시계, 달력 위젯은 자체 설정 UI 사용) */}
{/* 설정 버튼 (시계, 달력, 기사관리 위젯은 자체 설정 UI 사용) */}
{onConfigure &&
!(element.type === "widget" && (element.subtype === "clock" || element.subtype === "calendar")) && (
!(
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)}
@ -405,6 +412,16 @@ export function CanvasElement({
}}
/>
</div>
) : element.type === "widget" && element.subtype === "driver-management" ? (
// 기사 관리 위젯 렌더링
<div className="h-full w-full">
<DriverManagementWidget
element={element}
onConfigUpdate={(newConfig) => {
onUpdate(element.id, { driverManagementConfig: newConfig });
}}
/>
</div>
) : (
// 기타 위젯 렌더링
<div

View File

@ -0,0 +1,345 @@
# 기사 관리 위젯 구현 계획
## 개요
대시보드에 추가할 수 있는 기사 관리 위젯을 구현합니다. 실시간으로 기사와 차량의 운행 상태를 확인하고 관리할 수 있는 기능을 제공합니다.
## 주요 기능
### 1. 기사 정보 표시
- **차량 번호**: 예) 12가 3456
- **기사 이름**: 예) 홍길동
- **출발지**: 예) 서울시 강남구
- **목적지**: 예) 경기도 성남시
- **차량 유형**: 예) 1톤 트럭, 2.5톤 트럭, 5톤 트럭, 카고, 탑차, 냉동차 등
- **운행 상태**: 대기중, 운행중, 휴식중, 점검중
- **연락처**: 기사 전화번호
- **운행 시작 시간**: 출발 시간
- **예상 도착 시간**: 목적지 도착 예정 시간
### 2. 운행 상태 구분
- **대기중** (회색): 출발지/목적지가 없는 상태
- **운행중** (초록색): 출발지/목적지가 있고 운행 중
- **휴식중** (주황색): 휴게 중
- **점검중** (빨간색): 차량 점검 또는 수리 중
### 3. 뷰 타입
- **리스트 뷰**: 테이블 형식으로 전체 기사 목록 표시
- **맵 뷰** (향후 확장): 지도에 기사 위치 표시
### 4. 필터링 및 검색
- **상태별 필터**: 운행중, 대기중, 휴식중, 점검중
- **차량 유형별 필터**: 1톤, 2.5톤, 5톤 등
- **검색**: 기사 이름, 차량 번호로 검색
### 5. 정렬 기능
- 기사 이름순
- 차량 번호순
- 출발 시간순
- 운행 상태별
### 6. 설정 옵션
- **뷰 타입**: 리스트
- **자동 새로고침**: 실시간 데이터 갱신 (10초, 30초, 1분)
- **표시 항목**: 사용자가 원하는 컬럼만 표시
- **테마**: Light, Dark, 사용자 지정
## 데이터 구조
```typescript
interface DriverInfo {
id: string; // 기사 고유 ID
name: string; // 기사 이름
vehicleNumber: string; // 차량 번호
vehicleType: string; // 차량 유형
phone: string; // 연락처
status: "standby" | "driving" | "resting" | "maintenance"; // 운행 상태
departure?: string; // 출발지 (운행 중일 때)
destination?: string; // 목적지 (운행 중일 때)
departureTime?: string; // 출발 시간
estimatedArrival?: string; // 예상 도착 시간
progress?: number; // 운행 진행률 (0-100)
}
```
## 목업 데이터
```typescript
const MOCK_DRIVERS: DriverInfo[] = [
{
id: "DRV001",
name: "홍길동",
vehicleNumber: "12가 3456",
vehicleType: "1톤 트럭",
phone: "010-1234-5678",
status: "driving",
departure: "서울시 강남구",
destination: "경기도 성남시",
departureTime: "2025-10-14T09:00:00",
estimatedArrival: "2025-10-14T11:30:00",
progress: 65,
},
{
id: "DRV002",
name: "김철수",
vehicleNumber: "34나 7890",
vehicleType: "2.5톤 트럭",
phone: "010-2345-6789",
status: "standby",
},
{
id: "DRV003",
name: "이영희",
vehicleNumber: "56다 1234",
vehicleType: "5톤 트럭",
phone: "010-3456-7890",
status: "driving",
departure: "인천광역시",
destination: "충청남도 천안시",
departureTime: "2025-10-14T08:30:00",
estimatedArrival: "2025-10-14T10:00:00",
progress: 85,
},
{
id: "DRV004",
name: "박민수",
vehicleNumber: "78라 5678",
vehicleType: "카고",
phone: "010-4567-8901",
status: "resting",
},
{
id: "DRV005",
name: "정수진",
vehicleNumber: "90마 9012",
vehicleType: "냉동차",
phone: "010-5678-9012",
status: "maintenance",
},
];
```
## 구현 단계
### ✅ Step 1: 타입 정의
- [x] `DriverManagementConfig` 인터페이스 정의
- [x] `DriverInfo` 인터페이스 정의
- [x] `types.ts`에 기사 관리 설정 타입 추가
- [x] 요소 타입에 'driver-management' subtype 추가
### ✅ Step 2: 목업 데이터 생성
- [x] `driverMockData.ts` - 기사 목업 데이터 생성
- [x] 다양한 운행 상태의 샘플 데이터 (15개)
- [x] 차량 유형별 샘플 데이터
### ✅ Step 3: 유틸리티 함수
- [x] `driverUtils.ts` - 기사 관리 유틸리티 함수
- [x] 운행 상태별 색상 반환
- [x] 진행률 계산
- [x] 시간 포맷팅
- [x] 필터링/정렬 로직
### ✅ Step 4: 리스트 뷰 컴포넌트
- [x] `DriverListView.tsx` - 테이블 형식 리스트 뷰
- [x] 상태별 색상 구분
- [x] 정렬 기능 (유틸리티에서 처리)
- [x] 반응형 테이블 디자인 (컴팩트 모드 포함)
### ✅ Step 5: 카드 뷰 컴포넌트
- [x] 카드 뷰는 현재 구현하지 않음 (리스트 뷰만 사용)
- [ ] `DriverCardView.tsx` - 향후 추가 예정
### ✅ Step 6: 메인 위젯 컴포넌트
- [x] `DriverManagementWidget.tsx` - 메인 위젯
- [x] 리스트 뷰 표시
- [x] 필터링 UI (상태별)
- [x] 검색 기능
- [x] 자동 새로고침 (시뮬레이션)
### ✅ Step 7: 설정 UI
- [x] `DriverManagementSettings.tsx` - 설정 컴포넌트
- [x] 자동 새로고침 간격 설정
- [x] 표시 컬럼 선택
- [x] 테마 설정
- [x] 정렬 기준 설정
### ✅ Step 8: 통합
- [x] `DashboardSidebar`에 기사 관리 위젯 추가
- [x] `CanvasElement`에서 기사 관리 위젯 렌더링
- [x] `DashboardDesigner`에 기본값 설정
- [x] `ElementConfigModal`에 예외 처리 추가
### ✅ Step 9: 스타일링 및 최적화
- [ ] 반응형 디자인 (다양한 위젯 크기 대응)
- [ ] 컴팩트 모드 (작은 크기)
- [ ] 로딩 상태 처리
- [ ] 빈 데이터 상태 처리
### ✅ Step 10: 향후 확장 기능
- [ ] 실제 REST API 연동
- [ ] 웹소켓을 통한 실시간 업데이트
- [ ] 맵 뷰 (지도에 기사 위치 표시)
- [ ] 기사별 상세 정보 모달
- [ ] 운행 이력 조회
- [ ] 알림 기능 (지연, 긴급 상황 등)
## 위젯 크기별 최적화
### 2x2 (최소 크기)
- 요약 정보만 표시 (운행중 기사 수, 대기 기사 수)
- 간단한 상태 표시
### 3x3
- 카드 뷰 (2-3개 기사 표시)
- 기본 정보 표시
### 4x3 이상 (권장)
- 리스트 뷰 또는 카드 뷰 전체 표시
- 필터링 및 검색 기능
- 모든 정보 표시
## 완료 기준
- [x] 기본 타입 정의 완료
- [x] 목업 데이터 생성 완료
- [x] 리스트 뷰 구현 완료
- [ ] 카드 뷰 구현 완료 (향후 추가)
- [x] 필터링/검색 기능 구현 완료
- [x] 설정 UI 구현 완료
- [x] 대시보드 통합 완료
- [ ] 다양한 크기에서 테스트 완료 (사용자 테스트 필요)
## 주의사항
1. **성능 최적화**: 많은 기사 데이터를 처리할 때 가상 스크롤링 고려
2. **실시간 업데이트**: 자동 새로고침 시 부드러운 전환 애니메이션
3. **접근성**: 키보드 네비게이션 지원
4. **에러 처리**: API 연동 시 에러 상태 처리
5. **반응형**: 작은 크기에서도 정보가 잘 보이도록 디자인
## 추가 개선 사항 제안
### 1. 통계 정보
- 오늘 총 운행 건수
- 평균 운행 시간
- 차량 유형별 운행 통계
### 2. 긴급 상황 알림
- 운행 지연 알림 (예상 시간 초과)
- 차량 점검 필요 알림
- 기사 연락 두절 알림
### 3. 배차 관리 (고급 기능)
- 대기 중인 기사에게 배차
- 운행 스케줄 관리
- 경로 최적화 제안
### 4. 보고서 기능
- 일일 운행 보고서
- 기사별 운행 실적
- 차량별 가동률
---
## 🎯 구현 우선순위
1. **필수 (Phase 1)**
- 타입 정의
- 목업 데이터
- 리스트 뷰
- 기본 필터링
2. **중요 (Phase 2)**
- 카드 뷰
- 검색 기능
- 설정 UI
- 자동 새로고침
3. **추가 (Phase 3)**
- 통계 정보
- 상세 정보 모달
- 운행 이력
4. **향후 (Phase 4)**
- 맵 뷰
- 실시간 위치 추적
- 배차 관리
- 보고서 기능
---
**구현 시작일**: 2025-10-14
**구현 완료일**: 2025-10-14
**현재 진행률**: 90% (카드 뷰 및 최종 테스트 제외)
## 🎉 구현 완료!
기사 관리 위젯의 핵심 기능이 모두 구현되었습니다!
### ✅ 구현된 기능
1. **데이터 구조**
- DriverInfo, DriverManagementConfig 타입 정의
- 15개의 다양한 목업 데이터
- 6가지 차량 유형 지원
2. **리스트 뷰**
- 테이블 형식의 깔끔한 UI
- 상태별 색상 구분 (운행중/대기중/휴식중/점검중)
- 컴팩트 모드 지원 (2x2 크기)
3. **필터링 및 검색**
- 상태별 필터 (전체/운행중/대기중/휴식중/점검중)
- 기사명, 차량번호 검색
- 실시간 필터링
4. **정렬 기능**
- 기사명, 차량번호, 운행상태, 출발시간 기준 정렬
- 오름차순/내림차순 지원
5. **자동 새로고침**
- 10초/30초/1분/5분 간격 설정 가능
- 실시간 데이터 시뮬레이션
6. **설정 UI**
- Popover 방식의 직관적인 설정
- 표시 컬럼 선택 (9개 컬럼)
- 테마 설정 (Light/Dark/Custom)
- 정렬 기준 및 순서 설정
7. **대시보드 통합**
- 사이드바에 드래그 가능한 위젯 추가
- 캔버스에서 자유로운 배치 및 크기 조절
- 설정 저장 및 불러오기
### 🚀 향후 개선 사항
- 카드 뷰 구현
- 맵 뷰 (지도 연동)
- 실제 REST API 연동
- 웹소켓 실시간 업데이트
- 통계 정보 추가
- 배차 관리 기능

View File

@ -239,7 +239,7 @@ export default function DashboardDesigner() {
<div className="relative flex-1 overflow-auto border-r-2 border-gray-300 bg-gray-100">
{/* 편집 중인 대시보드 표시 */}
{dashboardTitle && (
<div className="bg-accent0 absolute top-6 left-6 z-10 rounded-lg px-3 py-1 text-sm font-medium text-white shadow-lg">
<div className="bg-accent0 absolute left-6 top-6 z-10 rounded-lg px-3 py-1 text-sm font-medium text-white shadow-lg">
📝 : {dashboardTitle}
</div>
)}
@ -302,6 +302,8 @@ function getElementTitle(type: ElementType, subtype: ElementSubtype): string {
return "🧮 계산기 위젯";
case "calendar":
return "📅 달력 위젯";
case "driver-management":
return "🚚 기사 관리 위젯";
default:
return "🔧 위젯";
}
@ -334,6 +336,8 @@ function getElementContent(type: ElementType, subtype: ElementSubtype): string {
return "calculator";
case "calendar":
return "calendar";
case "driver-management":
return "driver-management";
default:
return "위젯 내용이 여기에 표시됩니다";
}

View File

@ -127,6 +127,14 @@ export function DashboardSidebar() {
onDragStart={handleDragStart}
className="border-l-4 border-indigo-500"
/>
<DraggableItem
icon="🚚"
title="기사 관리 위젯"
type="widget"
subtype="driver-management"
onDragStart={handleDragStart}
className="border-l-4 border-blue-500"
/>
</div>
</div>
</div>

View File

@ -59,13 +59,16 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
// 모달이 열려있지 않으면 렌더링하지 않음
if (!isOpen) return null;
// 시계, 달력 위젯은 자체 설정 UI를 가지고 있으므로 모달 표시하지 않음
if (element.type === "widget" && (element.subtype === "clock" || element.subtype === "calendar")) {
// 시계, 달력, 기사관리 위젯은 자체 설정 UI를 가지고 있으므로 모달 표시하지 않음
if (
element.type === "widget" &&
(element.subtype === "clock" || element.subtype === "calendar" || element.subtype === "driver-management")
) {
return null;
}
return (
<div className="bg-opacity-50 fixed inset-0 z-50 flex items-center justify-center bg-black">
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50">
<div className="flex h-[80vh] w-full max-w-4xl flex-col rounded-lg bg-white shadow-xl">
{/* 모달 헤더 */}
<div className="flex items-center justify-between border-b border-gray-200 p-6">

View File

@ -16,7 +16,8 @@ export type ElementSubtype =
| "weather"
| "clock"
| "calendar"
| "calculator"; // 위젯 타입
| "calculator"
| "driver-management"; // 위젯 타입
export interface Position {
x: number;
@ -40,6 +41,7 @@ export interface DashboardElement {
chartConfig?: ChartConfig; // 차트 설정
clockConfig?: ClockConfig; // 시계 설정
calendarConfig?: CalendarConfig; // 달력 설정
driverManagementConfig?: DriverManagementConfig; // 기사 관리 설정
}
export interface DragData {
@ -101,3 +103,30 @@ export interface CalendarConfig {
customColor?: string; // 사용자 지정 색상
showWeekNumbers?: boolean; // 주차 표시 (선택)
}
// 기사 관리 위젯 설정
export interface DriverManagementConfig {
viewType: "list"; // 뷰 타입 (현재는 리스트만)
autoRefreshInterval: number; // 자동 새로고침 간격 (초)
visibleColumns: string[]; // 표시할 컬럼 목록
theme: "light" | "dark" | "custom"; // 테마
customColor?: string; // 사용자 지정 색상
statusFilter: "all" | "driving" | "standby" | "resting" | "maintenance"; // 상태 필터
sortBy: "name" | "vehicleNumber" | "status" | "departureTime"; // 정렬 기준
sortOrder: "asc" | "desc"; // 정렬 순서
}
// 기사 정보
export interface DriverInfo {
id: string; // 기사 고유 ID
name: string; // 기사 이름
vehicleNumber: string; // 차량 번호
vehicleType: string; // 차량 유형
phone: string; // 연락처
status: "standby" | "driving" | "resting" | "maintenance"; // 운행 상태
departure?: string; // 출발지
destination?: string; // 목적지
departureTime?: string; // 출발 시간
estimatedArrival?: string; // 예상 도착 시간
progress?: number; // 운행 진행률 (0-100)
}

View File

@ -0,0 +1,161 @@
"use client";
import React from "react";
import { DriverInfo, DriverManagementConfig } from "../types";
import { getStatusColor, getStatusLabel, formatTime, COLUMN_LABELS } from "./driverUtils";
import { Progress } from "@/components/ui/progress";
interface DriverListViewProps {
drivers: DriverInfo[];
config: DriverManagementConfig;
isCompact?: boolean; // 작은 크기 (2x2 등)
}
export function DriverListView({ drivers, config, isCompact = false }: DriverListViewProps) {
const { visibleColumns } = config;
// 컴팩트 모드: 요약 정보만 표시
if (isCompact) {
const stats = {
driving: drivers.filter((d) => d.status === "driving").length,
standby: drivers.filter((d) => d.status === "standby").length,
resting: drivers.filter((d) => d.status === "resting").length,
maintenance: drivers.filter((d) => d.status === "maintenance").length,
};
return (
<div className="flex h-full flex-col items-center justify-center space-y-3 p-4">
<div className="text-center">
<div className="text-3xl font-bold text-gray-900">{drivers.length}</div>
<div className="text-sm text-gray-600"> </div>
</div>
<div className="grid w-full grid-cols-2 gap-2 text-center text-xs">
<div className="rounded-lg bg-green-100 p-2">
<div className="font-semibold text-green-800">{stats.driving}</div>
<div className="text-green-600"></div>
</div>
<div className="rounded-lg bg-gray-100 p-2">
<div className="font-semibold text-gray-800">{stats.standby}</div>
<div className="text-gray-600"></div>
</div>
<div className="rounded-lg bg-orange-100 p-2">
<div className="font-semibold text-orange-800">{stats.resting}</div>
<div className="text-orange-600"></div>
</div>
<div className="rounded-lg bg-red-100 p-2">
<div className="font-semibold text-red-800">{stats.maintenance}</div>
<div className="text-red-600"></div>
</div>
</div>
</div>
);
}
// 빈 데이터 처리
if (drivers.length === 0) {
return (
<div className="flex h-full items-center justify-center text-sm text-gray-500"> </div>
);
}
return (
<div className="h-full w-full overflow-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="sticky top-0 z-10 bg-gray-50">
<tr>
{visibleColumns.includes("status") && (
<th className="px-3 py-2 text-left text-xs font-semibold text-gray-700">{COLUMN_LABELS.status}</th>
)}
{visibleColumns.includes("name") && (
<th className="px-3 py-2 text-left text-xs font-semibold text-gray-700">{COLUMN_LABELS.name}</th>
)}
{visibleColumns.includes("vehicleNumber") && (
<th className="px-3 py-2 text-left text-xs font-semibold text-gray-700">{COLUMN_LABELS.vehicleNumber}</th>
)}
{visibleColumns.includes("vehicleType") && (
<th className="px-3 py-2 text-left text-xs font-semibold text-gray-700">{COLUMN_LABELS.vehicleType}</th>
)}
{visibleColumns.includes("departure") && (
<th className="px-3 py-2 text-left text-xs font-semibold text-gray-700">{COLUMN_LABELS.departure}</th>
)}
{visibleColumns.includes("destination") && (
<th className="px-3 py-2 text-left text-xs font-semibold text-gray-700">{COLUMN_LABELS.destination}</th>
)}
{visibleColumns.includes("departureTime") && (
<th className="px-3 py-2 text-left text-xs font-semibold text-gray-700">{COLUMN_LABELS.departureTime}</th>
)}
{visibleColumns.includes("estimatedArrival") && (
<th className="px-3 py-2 text-left text-xs font-semibold text-gray-700">
{COLUMN_LABELS.estimatedArrival}
</th>
)}
{visibleColumns.includes("phone") && (
<th className="px-3 py-2 text-left text-xs font-semibold text-gray-700">{COLUMN_LABELS.phone}</th>
)}
{visibleColumns.includes("progress") && (
<th className="px-3 py-2 text-left text-xs font-semibold text-gray-700">{COLUMN_LABELS.progress}</th>
)}
</tr>
</thead>
<tbody className="divide-y divide-gray-200 bg-white">
{drivers.map((driver) => {
const statusColors = getStatusColor(driver.status);
return (
<tr key={driver.id} className="transition-colors hover:bg-gray-50">
{visibleColumns.includes("status") && (
<td className="px-3 py-2">
<span
className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${statusColors.bg} ${statusColors.text}`}
>
{getStatusLabel(driver.status)}
</span>
</td>
)}
{visibleColumns.includes("name") && (
<td className="px-3 py-2 text-sm font-medium text-gray-900">{driver.name}</td>
)}
{visibleColumns.includes("vehicleNumber") && (
<td className="px-3 py-2 text-sm text-gray-700">{driver.vehicleNumber}</td>
)}
{visibleColumns.includes("vehicleType") && (
<td className="px-3 py-2 text-sm text-gray-600">{driver.vehicleType}</td>
)}
{visibleColumns.includes("departure") && (
<td className="px-3 py-2 text-sm text-gray-700">
{driver.departure || <span className="text-gray-400">-</span>}
</td>
)}
{visibleColumns.includes("destination") && (
<td className="px-3 py-2 text-sm text-gray-700">
{driver.destination || <span className="text-gray-400">-</span>}
</td>
)}
{visibleColumns.includes("departureTime") && (
<td className="px-3 py-2 text-sm text-gray-600">{formatTime(driver.departureTime)}</td>
)}
{visibleColumns.includes("estimatedArrival") && (
<td className="px-3 py-2 text-sm text-gray-600">{formatTime(driver.estimatedArrival)}</td>
)}
{visibleColumns.includes("phone") && (
<td className="px-3 py-2 text-sm text-gray-600">{driver.phone}</td>
)}
{visibleColumns.includes("progress") && (
<td className="px-3 py-2">
{driver.progress !== undefined ? (
<div className="flex items-center space-x-2">
<Progress value={driver.progress} className="h-2 w-16" />
<span className="text-xs text-gray-600">{driver.progress}%</span>
</div>
) : (
<span className="text-gray-400">-</span>
)}
</td>
)}
</tr>
);
})}
</tbody>
</table>
</div>
);
}

View File

@ -0,0 +1,195 @@
"use client";
import { useState } from "react";
import { DriverManagementConfig } from "../types";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Input } from "@/components/ui/input";
import { Card } from "@/components/ui/card";
import { COLUMN_LABELS, DEFAULT_VISIBLE_COLUMNS } from "./driverUtils";
interface DriverManagementSettingsProps {
config: DriverManagementConfig;
onSave: (config: DriverManagementConfig) => void;
onClose: () => void;
}
export function DriverManagementSettings({ config, onSave, onClose }: DriverManagementSettingsProps) {
const [localConfig, setLocalConfig] = useState<DriverManagementConfig>(config);
const handleSave = () => {
onSave(localConfig);
};
// 컬럼 토글
const toggleColumn = (column: string) => {
const newColumns = localConfig.visibleColumns.includes(column)
? localConfig.visibleColumns.filter((c) => c !== column)
: [...localConfig.visibleColumns, column];
setLocalConfig({ ...localConfig, visibleColumns: newColumns });
};
return (
<div className="flex h-full max-h-[600px] flex-col overflow-hidden">
<div className="flex-1 space-y-6 overflow-y-auto p-6">
{/* 자동 새로고침 */}
<div className="space-y-3">
<Label className="text-sm font-semibold"> </Label>
<Select
value={String(localConfig.autoRefreshInterval)}
onValueChange={(value) => setLocalConfig({ ...localConfig, autoRefreshInterval: parseInt(value) })}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="0"> </SelectItem>
<SelectItem value="10">10</SelectItem>
<SelectItem value="30">30</SelectItem>
<SelectItem value="60">1</SelectItem>
<SelectItem value="300">5</SelectItem>
</SelectContent>
</Select>
</div>
{/* 정렬 설정 */}
<div className="space-y-3">
<Label className="text-sm font-semibold"> </Label>
<div className="grid grid-cols-2 gap-3">
<Select
value={localConfig.sortBy}
onValueChange={(value) =>
setLocalConfig({ ...localConfig, sortBy: value as DriverManagementConfig["sortBy"] })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="name"></SelectItem>
<SelectItem value="vehicleNumber"></SelectItem>
<SelectItem value="status"></SelectItem>
<SelectItem value="departureTime"></SelectItem>
</SelectContent>
</Select>
<Select
value={localConfig.sortOrder}
onValueChange={(value) =>
setLocalConfig({ ...localConfig, sortOrder: value as DriverManagementConfig["sortOrder"] })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="asc"></SelectItem>
<SelectItem value="desc"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* 테마 설정 */}
<div className="space-y-3">
<Label className="text-sm font-semibold"></Label>
<div className="grid grid-cols-3 gap-3">
<Button
type="button"
variant={localConfig.theme === "light" ? "default" : "outline"}
className="w-full"
onClick={() => setLocalConfig({ ...localConfig, theme: "light" })}
>
Light
</Button>
<Button
type="button"
variant={localConfig.theme === "dark" ? "default" : "outline"}
className="w-full"
onClick={() => setLocalConfig({ ...localConfig, theme: "dark" })}
>
🌙 Dark
</Button>
<Button
type="button"
variant={localConfig.theme === "custom" ? "default" : "outline"}
className="w-full"
onClick={() => setLocalConfig({ ...localConfig, theme: "custom" })}
>
🎨 Custom
</Button>
</div>
{/* 사용자 지정 색상 */}
{localConfig.theme === "custom" && (
<Card className="border p-4">
<Label className="mb-2 block text-sm font-medium"> </Label>
<div className="flex items-center gap-3">
<Input
type="color"
value={localConfig.customColor || "#3b82f6"}
onChange={(e) => setLocalConfig({ ...localConfig, customColor: e.target.value })}
className="h-12 w-20 cursor-pointer"
/>
<div className="flex-1">
<Input
type="text"
value={localConfig.customColor || "#3b82f6"}
onChange={(e) => setLocalConfig({ ...localConfig, customColor: e.target.value })}
placeholder="#3b82f6"
className="font-mono"
/>
<p className="text-muted-foreground mt-1 text-xs"> </p>
</div>
</div>
</Card>
)}
</div>
{/* 표시 컬럼 선택 */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-sm font-semibold"> </Label>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setLocalConfig({ ...localConfig, visibleColumns: DEFAULT_VISIBLE_COLUMNS })}
>
</Button>
</div>
<div className="grid grid-cols-2 gap-2">
{Object.entries(COLUMN_LABELS).map(([key, label]) => (
<Card
key={key}
className={`cursor-pointer border p-3 transition-colors ${
localConfig.visibleColumns.includes(key) ? "border-primary bg-primary/5" : "hover:bg-gray-50"
}`}
onClick={() => toggleColumn(key)}
>
<div className="flex items-center justify-between">
<Label className="cursor-pointer text-sm font-medium">{label}</Label>
<Switch
checked={localConfig.visibleColumns.includes(key)}
onCheckedChange={() => toggleColumn(key)}
/>
</div>
</Card>
))}
</div>
</div>
</div>
{/* 푸터 - 고정 */}
<div className="flex flex-shrink-0 justify-end gap-3 border-t border-gray-200 bg-gray-50 p-4">
<Button variant="outline" onClick={onClose}>
</Button>
<Button onClick={handleSave}></Button>
</div>
</div>
);
}

View File

@ -0,0 +1,159 @@
"use client";
import React, { useState, useEffect } from "react";
import { DashboardElement, DriverManagementConfig, DriverInfo } from "../types";
import { DriverListView } from "./DriverListView";
import { DriverManagementSettings } from "./DriverManagementSettings";
import { MOCK_DRIVERS } from "./driverMockData";
import { filterDrivers, sortDrivers, DEFAULT_VISIBLE_COLUMNS } from "./driverUtils";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Settings, Search, RefreshCw } from "lucide-react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
interface DriverManagementWidgetProps {
element: DashboardElement;
onConfigUpdate?: (config: DriverManagementConfig) => void;
}
export function DriverManagementWidget({ element, onConfigUpdate }: DriverManagementWidgetProps) {
const [drivers, setDrivers] = useState<DriverInfo[]>(MOCK_DRIVERS);
const [searchTerm, setSearchTerm] = useState("");
const [settingsOpen, setSettingsOpen] = useState(false);
const [lastRefresh, setLastRefresh] = useState(new Date());
// 기본 설정
const config = element.driverManagementConfig || {
viewType: "list",
autoRefreshInterval: 30,
visibleColumns: DEFAULT_VISIBLE_COLUMNS,
theme: "light",
statusFilter: "all",
sortBy: "name",
sortOrder: "asc",
};
// 자동 새로고침
useEffect(() => {
if (config.autoRefreshInterval <= 0) return;
const interval = setInterval(() => {
// 실제 환경에서는 API 호출
setDrivers(MOCK_DRIVERS);
setLastRefresh(new Date());
}, config.autoRefreshInterval * 1000);
return () => clearInterval(interval);
}, [config.autoRefreshInterval]);
// 수동 새로고침
const handleRefresh = () => {
setDrivers(MOCK_DRIVERS);
setLastRefresh(new Date());
};
// 설정 저장
const handleSaveSettings = (newConfig: DriverManagementConfig) => {
onConfigUpdate?.(newConfig);
setSettingsOpen(false);
};
// 필터링 및 정렬
const filteredDrivers = sortDrivers(
filterDrivers(drivers, config.statusFilter, searchTerm),
config.sortBy,
config.sortOrder,
);
// 컴팩트 모드 판단 (위젯 크기가 작을 때)
const isCompact = element.size.width < 400 || element.size.height < 300;
return (
<div className="relative flex h-full w-full flex-col bg-white">
{/* 헤더 - 컴팩트 모드가 아닐 때만 표시 */}
{!isCompact && (
<div className="flex-shrink-0 border-b border-gray-200 bg-gray-50 px-3 py-2">
<div className="flex items-center justify-between gap-2">
{/* 검색 */}
<div className="relative max-w-xs flex-1">
<Search className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-gray-400" />
<Input
type="text"
placeholder="기사명, 차량번호 검색"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="h-8 pl-8 text-xs"
/>
</div>
{/* 상태 필터 */}
<Select
value={config.statusFilter}
onValueChange={(value) => {
onConfigUpdate?.({
...config,
statusFilter: value as DriverManagementConfig["statusFilter"],
});
}}
>
<SelectTrigger className="h-8 w-24 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="driving"></SelectItem>
<SelectItem value="standby"></SelectItem>
<SelectItem value="resting"></SelectItem>
<SelectItem value="maintenance"></SelectItem>
</SelectContent>
</Select>
{/* 새로고침 버튼 */}
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={handleRefresh}>
<RefreshCw className="h-4 w-4" />
</Button>
{/* 설정 버튼 */}
<Popover open={settingsOpen} onOpenChange={setSettingsOpen}>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<Settings className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[500px] p-0" align="end">
<DriverManagementSettings
config={config}
onSave={handleSaveSettings}
onClose={() => setSettingsOpen(false)}
/>
</PopoverContent>
</Popover>
</div>
{/* 통계 정보 */}
<div className="mt-2 flex items-center gap-3 text-xs text-gray-600">
<span>
<span className="font-semibold text-gray-900">{filteredDrivers.length}</span>
</span>
<span className="text-gray-400">|</span>
<span>
{" "}
<span className="font-semibold text-green-600">
{filteredDrivers.filter((d) => d.status === "driving").length}
</span>
</span>
<span className="text-gray-400">|</span>
<span className="text-xs text-gray-500"> : {lastRefresh.toLocaleTimeString("ko-KR")}</span>
</div>
</div>
)}
{/* 리스트 뷰 */}
<div className="flex-1 overflow-hidden">
<DriverListView drivers={filteredDrivers} config={config} isCompact={isCompact} />
</div>
</div>
);
}

View File

@ -0,0 +1,181 @@
import { DriverInfo } from "../types";
/**
*
* REST API로
*/
export const MOCK_DRIVERS: DriverInfo[] = [
{
id: "DRV001",
name: "홍길동",
vehicleNumber: "12가 3456",
vehicleType: "1톤 트럭",
phone: "010-1234-5678",
status: "driving",
departure: "서울시 강남구",
destination: "경기도 성남시",
departureTime: "2025-10-14T09:00:00",
estimatedArrival: "2025-10-14T11:30:00",
progress: 65,
},
{
id: "DRV002",
name: "김철수",
vehicleNumber: "34나 7890",
vehicleType: "2.5톤 트럭",
phone: "010-2345-6789",
status: "standby",
},
{
id: "DRV003",
name: "이영희",
vehicleNumber: "56다 1234",
vehicleType: "5톤 트럭",
phone: "010-3456-7890",
status: "driving",
departure: "인천광역시",
destination: "충청남도 천안시",
departureTime: "2025-10-14T08:30:00",
estimatedArrival: "2025-10-14T10:00:00",
progress: 85,
},
{
id: "DRV004",
name: "박민수",
vehicleNumber: "78라 5678",
vehicleType: "카고",
phone: "010-4567-8901",
status: "resting",
},
{
id: "DRV005",
name: "정수진",
vehicleNumber: "90마 9012",
vehicleType: "냉동차",
phone: "010-5678-9012",
status: "maintenance",
},
{
id: "DRV006",
name: "최동욱",
vehicleNumber: "11아 3344",
vehicleType: "1톤 트럭",
phone: "010-6789-0123",
status: "driving",
departure: "부산광역시",
destination: "울산광역시",
departureTime: "2025-10-14T07:45:00",
estimatedArrival: "2025-10-14T09:15:00",
progress: 92,
},
{
id: "DRV007",
name: "강미선",
vehicleNumber: "22자 5566",
vehicleType: "탑차",
phone: "010-7890-1234",
status: "standby",
},
{
id: "DRV008",
name: "윤성호",
vehicleNumber: "33차 7788",
vehicleType: "2.5톤 트럭",
phone: "010-8901-2345",
status: "driving",
departure: "대전광역시",
destination: "세종특별자치시",
departureTime: "2025-10-14T10:20:00",
estimatedArrival: "2025-10-14T11:00:00",
progress: 45,
},
{
id: "DRV009",
name: "장혜진",
vehicleNumber: "44카 9900",
vehicleType: "냉동차",
phone: "010-9012-3456",
status: "resting",
},
{
id: "DRV010",
name: "임태양",
vehicleNumber: "55타 1122",
vehicleType: "5톤 트럭",
phone: "010-0123-4567",
status: "driving",
departure: "광주광역시",
destination: "전라남도 목포시",
departureTime: "2025-10-14T06:30:00",
estimatedArrival: "2025-10-14T08:45:00",
progress: 78,
},
{
id: "DRV011",
name: "오준석",
vehicleNumber: "66파 3344",
vehicleType: "카고",
phone: "010-1111-2222",
status: "standby",
},
{
id: "DRV012",
name: "한소희",
vehicleNumber: "77하 5566",
vehicleType: "1톤 트럭",
phone: "010-2222-3333",
status: "maintenance",
},
{
id: "DRV013",
name: "송민재",
vehicleNumber: "88거 7788",
vehicleType: "탑차",
phone: "010-3333-4444",
status: "driving",
departure: "경기도 수원시",
destination: "경기도 평택시",
departureTime: "2025-10-14T09:50:00",
estimatedArrival: "2025-10-14T11:20:00",
progress: 38,
},
{
id: "DRV014",
name: "배수지",
vehicleNumber: "99너 9900",
vehicleType: "2.5톤 트럭",
phone: "010-4444-5555",
status: "driving",
departure: "강원도 춘천시",
destination: "강원도 원주시",
departureTime: "2025-10-14T08:00:00",
estimatedArrival: "2025-10-14T09:30:00",
progress: 72,
},
{
id: "DRV015",
name: "신동엽",
vehicleNumber: "00더 1122",
vehicleType: "5톤 트럭",
phone: "010-5555-6666",
status: "standby",
},
];
/**
*
*/
export const VEHICLE_TYPES = ["1톤 트럭", "2.5톤 트럭", "5톤 트럭", "카고", "탑차", "냉동차"];
/**
*
*/
export function getDriverStatistics(drivers: DriverInfo[]) {
return {
total: drivers.length,
driving: drivers.filter((d) => d.status === "driving").length,
standby: drivers.filter((d) => d.status === "standby").length,
resting: drivers.filter((d) => d.status === "resting").length,
maintenance: drivers.filter((d) => d.status === "maintenance").length,
};
}

View File

@ -0,0 +1,256 @@
import { DriverInfo, DriverManagementConfig } from "../types";
/**
*
*/
export function getStatusColor(status: DriverInfo["status"]) {
switch (status) {
case "driving":
return {
bg: "bg-green-100",
text: "text-green-800",
border: "border-green-300",
badge: "bg-green-500",
};
case "standby":
return {
bg: "bg-gray-100",
text: "text-gray-800",
border: "border-gray-300",
badge: "bg-gray-500",
};
case "resting":
return {
bg: "bg-orange-100",
text: "text-orange-800",
border: "border-orange-300",
badge: "bg-orange-500",
};
case "maintenance":
return {
bg: "bg-red-100",
text: "text-red-800",
border: "border-red-300",
badge: "bg-red-500",
};
default:
return {
bg: "bg-gray-100",
text: "text-gray-800",
border: "border-gray-300",
badge: "bg-gray-500",
};
}
}
/**
*
*/
export function getStatusLabel(status: DriverInfo["status"]) {
switch (status) {
case "driving":
return "운행중";
case "standby":
return "대기중";
case "resting":
return "휴식중";
case "maintenance":
return "점검중";
default:
return "알 수 없음";
}
}
/**
* (HH:MM)
*/
export function formatTime(dateString?: string): string {
if (!dateString) return "-";
const date = new Date(dateString);
return date.toLocaleTimeString("ko-KR", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
}
/**
* (MM/DD HH:MM)
*/
export function formatDateTime(dateString?: string): string {
if (!dateString) return "-";
const date = new Date(dateString);
return date.toLocaleString("ko-KR", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
}
/**
* ( GPS )
*/
export function calculateProgress(driver: DriverInfo): number {
if (!driver.departureTime || !driver.estimatedArrival) return 0;
const now = new Date();
const departure = new Date(driver.departureTime);
const arrival = new Date(driver.estimatedArrival);
const totalTime = arrival.getTime() - departure.getTime();
const elapsedTime = now.getTime() - departure.getTime();
const progress = Math.min(100, Math.max(0, (elapsedTime / totalTime) * 100));
return Math.round(progress);
}
/**
*
*/
export function filterDrivers(
drivers: DriverInfo[],
statusFilter: DriverManagementConfig["statusFilter"],
searchTerm: string,
): DriverInfo[] {
let filtered = drivers;
// 상태 필터
if (statusFilter !== "all") {
filtered = filtered.filter((driver) => driver.status === statusFilter);
}
// 검색어 필터
if (searchTerm.trim()) {
const term = searchTerm.toLowerCase();
filtered = filtered.filter(
(driver) =>
driver.name.toLowerCase().includes(term) ||
driver.vehicleNumber.toLowerCase().includes(term) ||
driver.phone.includes(term),
);
}
return filtered;
}
/**
*
*/
export function sortDrivers(
drivers: DriverInfo[],
sortBy: DriverManagementConfig["sortBy"],
sortOrder: DriverManagementConfig["sortOrder"],
): DriverInfo[] {
const sorted = [...drivers];
sorted.sort((a, b) => {
let compareResult = 0;
switch (sortBy) {
case "name":
compareResult = a.name.localeCompare(b.name, "ko-KR");
break;
case "vehicleNumber":
compareResult = a.vehicleNumber.localeCompare(b.vehicleNumber);
break;
case "status":
const statusOrder = { driving: 0, resting: 1, standby: 2, maintenance: 3 };
compareResult = statusOrder[a.status] - statusOrder[b.status];
break;
case "departureTime":
const timeA = a.departureTime ? new Date(a.departureTime).getTime() : 0;
const timeB = b.departureTime ? new Date(b.departureTime).getTime() : 0;
compareResult = timeA - timeB;
break;
}
return sortOrder === "asc" ? compareResult : -compareResult;
});
return sorted;
}
/**
*
*/
export function getThemeColors(theme: string, customColor?: string) {
if (theme === "custom" && customColor) {
const lighterColor = adjustColor(customColor, 40);
const darkerColor = adjustColor(customColor, -40);
return {
background: lighterColor,
text: darkerColor,
border: customColor,
hover: customColor,
};
}
if (theme === "dark") {
return {
background: "#1f2937",
text: "#f3f4f6",
border: "#374151",
hover: "#374151",
};
}
// light theme (default)
return {
background: "#ffffff",
text: "#1f2937",
border: "#e5e7eb",
hover: "#f3f4f6",
};
}
/**
*
*/
function adjustColor(color: string, amount: number): string {
const clamp = (num: number) => Math.min(255, Math.max(0, num));
const hex = color.replace("#", "");
const r = parseInt(hex.substring(0, 2), 16);
const g = parseInt(hex.substring(2, 4), 16);
const b = parseInt(hex.substring(4, 6), 16);
const newR = clamp(r + amount);
const newG = clamp(g + amount);
const newB = clamp(b + amount);
return `#${newR.toString(16).padStart(2, "0")}${newG.toString(16).padStart(2, "0")}${newB.toString(16).padStart(2, "0")}`;
}
/**
*
*/
export const DEFAULT_VISIBLE_COLUMNS = [
"status",
"name",
"vehicleNumber",
"vehicleType",
"departure",
"destination",
"departureTime",
"estimatedArrival",
"phone",
];
/**
*
*/
export const COLUMN_LABELS: Record<string, string> = {
status: "상태",
name: "기사명",
vehicleNumber: "차량번호",
vehicleType: "차량유형",
departure: "출발지",
destination: "목적지",
departureTime: "출발시간",
estimatedArrival: "도착예정",
phone: "연락처",
progress: "진행률",
};