Spaces:
Sleeping
Sleeping
File size: 9,846 Bytes
33d9e63 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 | /**
* @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>
);
};
|