Spaces:
Running
Running
| /** | |
| * @license | |
| * SPDX-License-Identifier: Apache-2.0 | |
| */ | |
| import React from 'react'; | |
| import { Project } from '../../types'; | |
| import { projectService } from '../../services/projectService'; | |
| import { Search, Plus, Calendar, Users, Filter, BarChart2 } from 'lucide-react'; | |
| interface OpsProjectsListProps { | |
| onSelectProject: (projectId: string, tab: 'dashboard' | 'survey' | 'distribution') => void; | |
| onNavigateToCreateProject: () => void; | |
| } | |
| export const OpsProjectsList: React.FC<OpsProjectsListProps> = ({ | |
| onSelectProject, | |
| onNavigateToCreateProject, | |
| }) => { | |
| const [projects, setProjects] = React.useState<Project[]>([]); | |
| const [searchQuery, setSearchQuery] = React.useState(''); | |
| const [statusFilter, setStatusFilter] = React.useState<string>('all'); | |
| React.useEffect(() => { | |
| const loadAndSync = async () => { | |
| setProjects(projectService.getProjects()); | |
| try { | |
| await projectService.syncFromBackend(); | |
| setProjects(projectService.getProjects()); | |
| } catch (err) { | |
| console.error('Failed to sync projects on list load', err); | |
| } | |
| }; | |
| loadAndSync(); | |
| }, []); | |
| const handleDeleteProject = async (id: string, e: React.MouseEvent) => { | |
| e.stopPropagation(); | |
| if (window.confirm('정말로 이 프로젝트를 삭제하시겠습니까? 관련 모든 조직도 및 응답 정보가 지워집니다.')) { | |
| try { | |
| await projectService.deleteProject(id); | |
| const remaining = projects.filter((p) => p.id !== id); | |
| setProjects(remaining); | |
| } catch (err) { | |
| alert('프로젝트 삭제에 실패했습니다.'); | |
| } | |
| } | |
| }; | |
| // Filter projects | |
| const filteredProjects = projects.filter((proj) => { | |
| const matchesSearch = | |
| proj.title.toLowerCase().includes(searchQuery.toLowerCase()) || | |
| proj.company_info.name.toLowerCase().includes(searchQuery.toLowerCase()); | |
| const matchesStatus = statusFilter === 'all' || proj.status === statusFilter; | |
| return matchesSearch && matchesStatus; | |
| }); | |
| return ( | |
| <div className="space-y-6 font-sans antialiased" id="ops-projects-list-view"> | |
| {/* Top Header Controls */} | |
| <div className="flex flex-col md:flex-row md:items-center justify-between gap-4" id="projects-list-header"> | |
| <div> | |
| <h2 className="text-lg font-bold text-slate-900 tracking-tight">전체 프로젝트 목록</h2> | |
| <p className="text-sm text-slate-400 mt-1"> | |
| 등록된 모든 조직 네트워크 분석 진단 목록입니다. 검색 및 상태 필터를 지원합니다. | |
| </p> | |
| </div> | |
| <button | |
| onClick={onNavigateToCreateProject} | |
| className="inline-flex items-center gap-1.5 px-4 py-2 bg-orange-500 hover:bg-orange-600 active:bg-orange-700 text-white text-sm font-bold rounded-lg transition-colors cursor-pointer" | |
| id="create-project-btn" | |
| > | |
| <Plus className="w-4 h-4" /> | |
| 새 프로젝트 생성 | |
| </button> | |
| </div> | |
| {/* Filter and Search Bar Row */} | |
| <div | |
| className="bg-white p-4 rounded-xl border border-slate-100 flex flex-col md:flex-row gap-4" | |
| id="projects-filter-bar" | |
| > | |
| {/* Search */} | |
| <div className="relative flex-1"> | |
| <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" /> | |
| <input | |
| type="text" | |
| placeholder="프로젝트명 또는 고객사명 검색..." | |
| value={searchQuery} | |
| onChange={(e) => setSearchQuery(e.target.value)} | |
| className="w-full pl-9 pr-4 py-2 text-sm border border-slate-200 focus:bg-white focus:ring-2 focus:ring-orange-500/10 focus:border-orange-500 rounded-lg transition-all outline-none" | |
| id="projects-search-input" | |
| /> | |
| </div> | |
| {/* Filter */} | |
| <div className="flex items-center gap-2" id="status-filter-wrapper"> | |
| <Filter className="w-4 h-4 text-slate-400 shrink-0" /> | |
| <select | |
| value={statusFilter} | |
| onChange={(e) => setStatusFilter(e.target.value)} | |
| className="px-3 py-2 text-sm border border-slate-200 bg-white rounded-lg outline-none focus:ring-2 focus:ring-orange-500/10 focus:border-orange-500 font-medium" | |
| id="projects-status-select" | |
| > | |
| <option value="all">모든 상태</option> | |
| <option value="draft">초안 (Draft)</option> | |
| <option value="collecting">수집 중 (Collecting)</option> | |
| <option value="closed">마감됨 (Closed)</option> | |
| </select> | |
| </div> | |
| </div> | |
| {/* Projects Grid List */} | |
| {filteredProjects.length === 0 ? ( | |
| <div | |
| className="bg-white p-16 text-center border border-dashed border-slate-200 rounded-xl" | |
| id="no-projects-view" | |
| > | |
| <p className="text-sm text-slate-400">검색 조건에 맞는 진단 프로젝트가 없습니다.</p> | |
| </div> | |
| ) : ( | |
| <div className="grid grid-cols-1 md:grid-cols-2 gap-4" id="projects-grid"> | |
| {filteredProjects.map((proj) => { | |
| const totalEmps = proj.employees.length; | |
| const submitted = proj.employees.filter((e) => e.response_status === 'submitted').length; | |
| const progressPct = totalEmps > 0 ? Math.round((submitted / totalEmps) * 100) : 0; | |
| return ( | |
| <div | |
| key={proj.id} | |
| onClick={() => onSelectProject(proj.id, 'dashboard')} | |
| className="bg-white border border-slate-100 hover:border-orange-200 rounded-xl p-5 transition-all cursor-pointer flex flex-col justify-between gap-4 group" | |
| id={`project-item-${proj.id}`} | |
| > | |
| <div className="space-y-2"> | |
| <div className="flex justify-between items-start gap-4"> | |
| <div className="space-y-0.5"> | |
| <span className="text-sm font-bold text-slate-400 block tracking-wide"> | |
| {proj.company_info.name} | |
| </span> | |
| <h3 className="text-base font-bold text-slate-900 line-clamp-1 group-hover:text-orange-600 transition-colors"> | |
| {proj.title} | |
| </h3> | |
| </div> | |
| <span | |
| className={`px-1.5 py-0.5 text-sm font-bold rounded-sm uppercase shrink-0 border ${proj.status === 'collecting' | |
| ? 'bg-orange-50 text-orange-600 border-orange-100' | |
| : proj.status === 'draft' | |
| ? 'bg-gray-50 text-gray-500 border-gray-100' | |
| : 'bg-slate-50 text-slate-700 border-slate-150' | |
| }`} | |
| > | |
| {proj.status === 'collecting' | |
| ? '수집 중' | |
| : proj.status === 'draft' | |
| ? '초안' | |
| : '마감됨'} | |
| </span> | |
| </div> | |
| <div className="flex items-center gap-3 text-sm text-slate-400 pt-1"> | |
| <span className="flex items-center gap-1"> | |
| <Calendar className="w-3.5 h-3.5 text-slate-300" /> | |
| {proj.start_date} ~ {proj.end_date} | |
| </span> | |
| <span className="flex items-center gap-1"> | |
| <Users className="w-3.5 h-3.5 text-slate-300" /> | |
| {totalEmps}명 대상 | |
| </span> | |
| </div> | |
| </div> | |
| {/* Progress bar info */} | |
| <div className="space-y-1.5 border-t border-slate-100 pt-3"> | |
| <div className="flex justify-between text-sm font-semibold text-slate-500"> | |
| <span>진행률</span> | |
| <span> | |
| {submitted}/{totalEmps}명 ({progressPct}%) | |
| </span> | |
| </div> | |
| <div className="w-full bg-slate-100 h-1.5 rounded-full overflow-hidden"> | |
| <div | |
| className="bg-orange-500 h-1.5 rounded-full transition-all duration-500" | |
| style={{ width: `${progressPct}%` }} | |
| /> | |
| </div> | |
| </div> | |
| {/* Foot card actions */} | |
| <div className="flex justify-between items-center pt-1"> | |
| <span className="text-sm text-slate-400"> | |
| ID: <code className="font-mono text-sm text-slate-600">{proj.survey_login_id}</code> | |
| </span> | |
| <div className="flex gap-2"> | |
| <button | |
| onClick={(e) => handleDeleteProject(proj.id, e)} | |
| className="px-2 py-1 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-md text-sm font-semibold cursor-pointer" | |
| id={`project-delete-${proj.id}`} | |
| > | |
| 삭제 | |
| </button> | |
| <button | |
| onClick={(e) => { | |
| e.stopPropagation(); | |
| onSelectProject(proj.id, 'dashboard'); | |
| }} | |
| className="px-2.5 py-1 bg-orange-50 text-orange-600 group-hover:bg-orange-500 group-hover:text-white rounded-md text-sm font-bold transition-all inline-flex items-center gap-1 cursor-pointer" | |
| id={`project-open-${proj.id}`} | |
| > | |
| <BarChart2 className="w-3.5 h-3.5" /> | |
| 상세 보기 | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| }; | |