feat(부서관리): 기준일 필터 + 시작일/종료일 UI 노출

- 백엔드: selectDepartments 에 base_date <if> 블록 추가 (start_date <= base_date AND (end_date IS NULL OR end_date >= base_date))
- 서비스/컨트롤러에 3-arg overload 와 @RequestParam("base_date") 추가
- 프론트: 사용기간 RadioGroup + 시작일/종료일 Row 의 {false &&} hide 제거
- loadDepartments 가 periodMode === "date" 일 때 baseDate 를 API 에 전달
This commit is contained in:
2026-05-14 14:12:59 +09:00
parent ca241c017d
commit ecad2915ce
5 changed files with 66 additions and 54 deletions
@@ -21,20 +21,22 @@ public class DepartmentController {
/**
* 부서 목록 조회 (회사별).
* 기본은 active 부서만. ?include_deleted=true 시 soft-delete 된 부서도 포함.
* GET /api/departments/companies/{companyCode}/departments[?include_deleted=true]
* ?base_date=YYYY-MM-DD 시 해당 시점에 active 했던 부서만 반환.
* GET /api/departments/companies/{companyCode}/departments[?include_deleted=true][&base_date=YYYY-MM-DD]
*/
@GetMapping("/companies/{companyCode}/departments")
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getDepartments(
@PathVariable String companyCode,
@RequestAttribute("company_code") String userCompanyCode,
@RequestParam(value = "include_deleted", required = false, defaultValue = "false") boolean includeDeleted) {
@RequestParam(value = "include_deleted", required = false, defaultValue = "false") boolean includeDeleted,
@RequestParam(value = "base_date", required = false) String baseDate) {
if (!isSuperAdmin(userCompanyCode) && !userCompanyCode.equals(companyCode)) {
return ResponseEntity.status(403)
.body(ApiResponse.error("해당 회사의 부서를 조회할 권한이 없습니다."));
}
List<Map<String, Object>> departments = departmentService.getDepartments(companyCode, includeDeleted);
List<Map<String, Object>> departments = departmentService.getDepartments(companyCode, includeDeleted, baseDate);
return ResponseEntity.ok(ApiResponse.success(departments, "부서 목록 조회 성공"));
}
@@ -20,17 +20,22 @@ public class DepartmentService extends BaseService {
// ──────────────────────────────────────────────────
public List<Map<String, Object>> getDepartments(String companyCode) {
return getDepartments(companyCode, false);
return getDepartments(companyCode, false, null);
}
/** soft-delete 대응 — includeDeleted=true 면 DELETED_AT 부서도 포함 */
public List<Map<String, Object>> getDepartments(String companyCode, boolean includeDeleted) {
return getDepartments(companyCode, includeDeleted, null);
}
/** 기준일 필터 — baseDate 가 있으면 해당 시점에 active 한 부서만 반환 (start_date ≤ baseDate ≤ end_date OR end_date IS NULL) */
public List<Map<String, Object>> getDepartments(String companyCode, boolean includeDeleted, String baseDate) {
Map<String, Object> params = new HashMap<>();
params.put("company_code", companyCode);
params.put("include_deleted", includeDeleted);
params.put("base_date", baseDate); // null/빈문자면 XML if 가 skip
List<Map<String, Object>> departments = sqlSession.selectList("department.selectDepartments", params);
// member_count를 int로 변환
for (Map<String, Object> dept : departments) {
Object cnt = dept.get("member_count");
if (cnt != null) {
@@ -30,6 +30,10 @@
<if test="include_deleted == null or include_deleted == false">
AND D.DELETED_AT IS NULL
</if>
<if test="base_date != null and base_date != ''">
AND D.START_DATE &lt;= #{base_date}::date
AND (D.END_DATE IS NULL OR D.END_DATE &gt;= #{base_date}::date)
</if>
GROUP BY
D.DEPT_CODE, D.DEPT_NAME, D.COMPANY_CODE, D.PARENT_DEPT_CODE,
D.SHORT_NAME, D.DEPT_TYPE, D.ORG_SYSTEM, D.APPROVAL_MANAGER, D.DEPT_MANAGER,
@@ -201,7 +201,10 @@ export default function DeptMngListPage() {
if (!selectedCompanyCode) return;
setIsTreeLoading(true);
try {
const res = await departmentAPI.getDepartments(selectedCompanyCode, { includeDeleted: showDeleted });
const res = await departmentAPI.getDepartments(selectedCompanyCode, {
includeDeleted: showDeleted,
baseDate: periodMode === "date" ? baseDate : undefined,
});
if (res.success && (res as any).data) {
setDepartments((res as any).data);
} else {
@@ -210,7 +213,7 @@ export default function DeptMngListPage() {
} finally {
setIsTreeLoading(false);
}
}, [selectedCompanyCode, showDeleted]);
}, [selectedCompanyCode, showDeleted, periodMode, baseDate]);
useEffect(() => {
loadDepartments();
@@ -645,33 +648,30 @@ export default function DeptMngListPage() {
<aside className="flex w-[340px] shrink-0 flex-col border-r">
{/* 기준일 / 회사 / 검색 */}
<div className="space-y-3 border-b p-3">
{/* TODO V2: 사용기간 필터 — backend 미구현, V1 hidden */}
{false && (
<div className="flex items-center gap-3">
<Label className="w-[60px] shrink-0 text-xs font-semibold"></Label>
<RadioGroup
value={periodMode}
onValueChange={(v) => setPeriodMode(v as "all" | "date")}
className="flex items-center gap-3"
>
<div className="flex items-center gap-1">
<RadioGroupItem value="all" id="period-all" className="h-3.5 w-3.5" />
<Label htmlFor="period-all" className="text-xs"></Label>
</div>
<div className="flex items-center gap-1">
<RadioGroupItem value="date" id="period-date" className="h-3.5 w-3.5" />
<Label htmlFor="period-date" className="text-xs"></Label>
</div>
</RadioGroup>
<Input
type="date"
value={baseDate}
onChange={(e) => setBaseDate(e.target.value)}
disabled={periodMode !== "date"}
className="h-7 flex-1 text-xs"
/>
</div>
)}
<div className="flex items-center gap-3">
<Label className="w-[60px] shrink-0 text-xs font-semibold"></Label>
<RadioGroup
value={periodMode}
onValueChange={(v) => setPeriodMode(v as "all" | "date")}
className="flex items-center gap-3"
>
<div className="flex items-center gap-1">
<RadioGroupItem value="all" id="period-all" className="h-3.5 w-3.5" />
<Label htmlFor="period-all" className="text-xs"></Label>
</div>
<div className="flex items-center gap-1">
<RadioGroupItem value="date" id="period-date" className="h-3.5 w-3.5" />
<Label htmlFor="period-date" className="text-xs"></Label>
</div>
</RadioGroup>
<Input
type="date"
value={baseDate}
onChange={(e) => setBaseDate(e.target.value)}
disabled={periodMode !== "date"}
className="h-7 flex-1 text-xs"
/>
</div>
<Select
value={selectedCompanyCode}
@@ -1423,28 +1423,25 @@ function BasicInfoForm({
</RadioGroup>
</Row>
{/* TODO V2: 사용기간 (시작일/종료일) — 필터 도입 시 사용. 컬럼은 DEPT_INFO.START_DATE/END_DATE 유지 */}
{false && (
<Row label="시작일">
<div className="grid grid-cols-2 gap-3">
<Row label="시작일">
<div className="grid grid-cols-2 gap-3">
<Input
type="date"
value={draft.start_date}
onChange={(e) => update("start_date", e.target.value)}
className="h-8 text-sm"
/>
<div className="flex items-center gap-2">
<Label className="w-[50px] text-xs"></Label>
<Input
type="date"
value={draft.start_date}
onChange={(e) => update("start_date", e.target.value)}
className="h-8 text-sm"
value={draft.end_date}
onChange={(e) => update("end_date", e.target.value)}
className="h-8 flex-1 text-sm"
/>
<div className="flex items-center gap-2">
<Label className="w-[50px] text-xs"></Label>
<Input
type="date"
value={draft.end_date}
onChange={(e) => update("end_date", e.target.value)}
className="h-8 flex-1 text-sm"
/>
</div>
</div>
</Row>
)}
</div>
</Row>
<Row label="정렬">
<Input
+6 -2
View File
@@ -8,15 +8,19 @@ import { Department, DepartmentMember, DepartmentFormData } from "@/types/depart
/**
* 부서 목록 조회 (회사별).
* options.includeDeleted=true 시 soft-delete 된 부서도 포함.
* options.baseDate (YYYY-MM-DD) 가 있으면 해당 시점에 active 한 부서만 반환.
*/
export async function getDepartments(
companyCode: string,
options?: { includeDeleted?: boolean },
options?: { includeDeleted?: boolean; baseDate?: string },
) {
try {
const url = `/departments/companies/${companyCode}/departments`;
const params: Record<string, any> = {};
if (options?.includeDeleted) params.include_deleted = true;
if (options?.baseDate) params.base_date = options.baseDate;
const response = await apiClient.get<{ success: boolean; data: Department[] }>(url, {
params: options?.includeDeleted ? { include_deleted: true } : undefined,
params: Object.keys(params).length > 0 ? params : undefined,
});
return response.data;
} catch (error: any) {