Files
studio-admin-fe/src/pages/operations/OrganizationsPage.tsx
T
chyoo ac075a1fea
ci/woodpecker/push/woodpecker Pipeline was successful
improve app registration and component service ports
2026-07-09 14:26:43 +09:00

338 lines
12 KiB
TypeScript

import { MoreHorizontal, Plus } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { DataTableCard, type DataTableColumn, type DataTableFilter } from '../../components/data-table';
import { useToast } from '../../components/toast';
import { Button, Modal } from '../../components/ui';
import { PageTitle } from '../../layout/AppLayout';
import { formatDateTime } from '../../lib/date-format';
import {
createOrganization,
deleteOrganization,
loadOrganizations,
updateOrganization,
type Organization,
type OrganizationType
} from './operations-api';
import { LoadingModal, StatusBadge, useMinimumLoading } from './operation-utils';
const organizationTypes: Array<{ value: OrganizationType; label: string }> = [
{ value: 'SCHOOL', label: '학교' },
{ value: 'COMPANY', label: '회사' },
{ value: 'GOVERNMENT', label: '공공기관' },
{ value: 'LAB', label: '연구소' },
{ value: 'OTHER', label: '기타' }
];
const emptyForm = {
name: '',
type: 'SCHOOL' as OrganizationType,
domain: ''
};
function organizationTypeLabel(type: OrganizationType) {
return organizationTypes.find((item) => item.value === type)?.label ?? type;
}
export function OrganizationsPage() {
const { toast } = useToast();
const [organizations, setOrganizations] = useState<Organization[]>([]);
const [loading, setLoading] = useState(true);
const [failed, setFailed] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<Organization | null>(null);
const [deleteTarget, setDeleteTarget] = useState<Organization | null>(null);
const [menuOrganization, setMenuOrganization] = useState<{ id: string; x: number; y: number } | null>(null);
const [saving, setSaving] = useState(false);
const [form, setForm] = useState(emptyForm);
const loadingVisible = useMinimumLoading(loading);
const refresh = () => {
setLoading(true);
loadOrganizations()
.then((items) => {
setOrganizations(items);
setFailed(false);
})
.catch((error: Error) => {
setOrganizations([]);
setFailed(true);
toast({ type: 'error', title: '조직 조회 실패', message: error.message });
})
.finally(() => setLoading(false));
};
useEffect(() => {
refresh();
}, []);
useEffect(() => {
if (!menuOrganization) return;
const close = () => setMenuOrganization(null);
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') setMenuOrganization(null);
};
window.addEventListener('click', close);
window.addEventListener('keydown', closeOnEscape);
return () => {
window.removeEventListener('click', close);
window.removeEventListener('keydown', closeOnEscape);
};
}, [menuOrganization]);
const rows = useMemo(
() => organizations.map((organization, index) => ({ ...organization, no: organizations.length - index })),
[organizations]
);
const columns = useMemo<Array<DataTableColumn<Organization>>>(
() => [
{ key: 'no', header: 'No.', className: 'w-14 px-3 py-3 text-center font-medium', cell: (row) => <span className="block text-center font-semibold">{row.no}</span> },
{
key: 'name',
header: '조직명',
className: 'px-5 py-3 font-medium',
cell: (row) => (
<div className="min-w-0">
<p className="truncate font-semibold">{row.name}</p>
<p className="mt-1 truncate text-xs text-muted-foreground">{row.id}</p>
</div>
)
},
{ key: 'type', header: '유형', className: 'w-32 px-5 py-3', cell: (row) => <StatusBadge status={organizationTypeLabel(row.type)} tone="blue" /> },
{ key: 'domain', header: '도메인', className: 'w-56 px-5 py-3', cell: (row) => <span className="font-mono text-xs text-muted-foreground">{row.domain || '-'}</span> },
{ key: 'createdAt', header: '등록일', className: 'w-40 px-5 py-3', cell: (row) => <span className="whitespace-nowrap text-muted-foreground">{formatDateTime(row.createdAt)}</span> },
{
key: 'actions',
header: '',
className: 'w-20 px-5 py-3 text-right font-medium',
cell: (row) => (
<div className="relative flex justify-end">
<Button
variant="ghost"
className="size-8 px-0"
title="메뉴"
aria-label="메뉴"
onClick={(event) => {
event.stopPropagation();
const rect = event.currentTarget.getBoundingClientRect();
setMenuOrganization((prev) => (prev?.id === row.id ? null : { id: row.id, x: rect.right, y: rect.bottom + 6 }));
}}
>
<MoreHorizontal className="size-4" />
</Button>
</div>
)
}
],
[menuOrganization?.id]
);
const filters = useMemo<Array<DataTableFilter<Organization>>>(
() => [
{
key: 'type',
label: '유형',
options: organizationTypes.map((type) => ({ label: type.label, value: type.value })),
getValue: (row) => row.type
}
],
[]
);
const closeModal = () => {
setModalOpen(false);
setEditing(null);
setForm(emptyForm);
setSaving(false);
};
const openCreateModal = () => {
setEditing(null);
setForm(emptyForm);
setModalOpen(true);
};
const openEditModal = (organization: Organization) => {
setEditing(organization);
setForm({
name: organization.name,
type: organization.type,
domain: organization.domain ?? ''
});
setModalOpen(true);
};
const submit = () => {
if (!form.name.trim()) return;
setSaving(true);
const payload = {
name: form.name.trim(),
type: form.type,
domain: form.domain.trim() || null
};
const request = editing
? updateOrganization(editing.id, payload)
: createOrganization(payload);
request
.then(({ organization }) => {
setOrganizations((prev) => editing
? prev.map((item) => item.id === organization.id ? organization : item)
: [organization, ...prev]);
toast({
type: 'success',
title: editing ? '조직 수정 완료' : '조직 등록 완료',
message: `${organization.name} 조직이 ${editing ? '수정' : '등록'}되었습니다.`
});
closeModal();
})
.catch((error: Error) => {
setSaving(false);
toast({ type: 'error', title: editing ? '조직 수정 실패' : '조직 등록 실패', message: error.message });
});
};
const confirmDelete = () => {
if (!deleteTarget) return;
setSaving(true);
deleteOrganization(deleteTarget.id)
.then(() => {
setOrganizations((prev) => prev.filter((organization) => organization.id !== deleteTarget.id));
toast({ type: 'success', title: '조직 삭제 완료', message: `${deleteTarget.name} 조직이 삭제되었습니다.` });
setDeleteTarget(null);
})
.catch((error: Error) => {
toast({ type: 'error', title: '조직 삭제 실패', message: error.message });
})
.finally(() => setSaving(false));
};
return (
<>
<PageTitle
eyebrow="운영"
title="조직 관리"
description="가입 사용자에게 연결할 회사, 학교, 기관, 연구소 정보를 관리합니다."
actions={
<Button className="h-8 px-3 text-[13px]" onClick={openCreateModal}>
<Plus className="size-4" />
추가
</Button>
}
/>
{failed ? (
<div className="mb-4 rounded-md border border-rose-500/30 bg-rose-500/10 px-4 py-3 text-sm text-rose-200">
조직 데이터를 불러오지 못했습니다.
</div>
) : null}
<DataTableCard
title="목록"
rows={rows}
columns={columns}
filters={filters}
searchPlaceholder="조직명, 도메인 검색"
getSearchText={(row) => `${row.name} ${row.type} ${organizationTypeLabel(row.type)} ${row.domain ?? ''}`}
getRowKey={(row) => row.id}
pageSize={10}
minWidth={980}
loading={loading}
emptyMessage="표시할 조직 데이터가 없습니다."
/>
{menuOrganization ? (() => {
const row = rows.find((item) => item.id === menuOrganization.id);
if (!row) return null;
return (
<div onClick={(event) => event.stopPropagation()} className="fixed z-[70] min-w-32 overflow-hidden rounded-md border border-border bg-card py-1 text-left shadow-xl" style={{ left: menuOrganization.x, top: menuOrganization.y, transform: 'translateX(-100%)' }}>
<button
type="button"
className="block w-full px-3 py-2 text-left text-xs text-foreground hover:bg-muted"
onClick={() => {
setMenuOrganization(null);
openEditModal(row);
}}
>
수정
</button>
<button
type="button"
className="block w-full px-3 py-2 text-left text-xs text-rose-300 hover:bg-muted hover:text-rose-200"
onClick={() => {
setMenuOrganization(null);
setDeleteTarget(row);
}}
>
삭제
</button>
</div>
);
})() : null}
<Modal
open={modalOpen}
title={editing ? '조직 수정' : '조직 추가'}
onClose={closeModal}
footer={
<>
<Button variant="outline" onClick={closeModal} disabled={saving}>취소</Button>
<Button onClick={submit} disabled={!form.name.trim() || saving}>{saving ? '저장 중' : editing ? '수정' : '등록'}</Button>
</>
}
>
<div className="space-y-4">
<label className="block">
<span className="text-xs font-medium text-muted-foreground">조직명 <b className="text-foreground">*</b></span>
<input
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
value={form.name}
onChange={(event) => setForm((prev) => ({ ...prev, name: event.target.value }))}
placeholder="예: 영남대학교"
/>
</label>
<label className="block">
<span className="text-xs font-medium text-muted-foreground">유형 <b className="text-foreground">*</b></span>
<select
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
value={form.type}
onChange={(event) => setForm((prev) => ({ ...prev, type: event.target.value }))}
>
{organizationTypes.map((type) => (
<option key={type.value} value={type.value}>{type.label}</option>
))}
</select>
</label>
<label className="block">
<span className="text-xs font-medium text-muted-foreground">도메인</span>
<input
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 font-mono text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
value={form.domain}
onChange={(event) => setForm((prev) => ({ ...prev, domain: event.target.value }))}
/>
</label>
</div>
</Modal>
<Modal
open={Boolean(deleteTarget)}
title="조직 삭제"
onClose={() => {
if (!saving) setDeleteTarget(null);
}}
footer={
<>
<Button variant="outline" onClick={() => setDeleteTarget(null)} disabled={saving}>취소</Button>
<Button onClick={confirmDelete} disabled={saving}>{saving ? '삭제 중' : '삭제'}</Button>
</>
}
>
<p className="text-sm text-muted-foreground">
{deleteTarget?.name ?? '-'} 조직을 삭제하시겠습니까? 삭제된 조직은 목록과 선택 항목에서 숨겨집니다.
</p>
</Modal>
<LoadingModal open={loadingVisible} />
</>
);
}