org-network / frontend /src /components /ops /OpsProjectsList.tsx
5minbetter's picture
deploy: initial clean workspace without lfs history
33d9e63
Raw
History Blame Contribute Delete
9.85 kB
/**
* @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>
);
};