fix(대무자): COMPANY_ADMIN 권한 허용 + 결재함 SQL 컬럼 오타 fix + UI 셀렉트 개선

운영 QA 에서 발견된 3가지 결함을 한 번에 수정.

1. SubstituteController.java:56 / SubstituteService.java:242 (requireAdmin)
   - role 비교에서 "COMPANY_ADMIN" 누락 → 운영 admin 이 대무자 지정 시 항상 403.
   - 운영 회사 admin 의 user_type 은 COMPANY_ADMIN 이 표준 (AdminAccountCreator 가 그렇게 생성).
   - "ADMIN" / "SUPER_ADMIN" 외 "COMPANY_ADMIN" 도 허용.

2. mapper/approval.xml (selectMyRequests, selectMyPendingLines)
   - ORDER BY / SELECT 의 R.CREATED_DATE 가 잘못된 컬럼명 (APPROVAL_REQUESTS 실제: created_at).
   - 결재함 /api/approval/my-pending, /api/approval/requests 가 항상 500.
   - 3군데 R.CREATED_DATE → R.CREATED_AT.

3. SubstituteSection.tsx
   - 대무자 ID 를 직접 타이핑하던 input 을 Select 로 교체.
   - getUserList 로 같은 회사 활성 사용자 목록 로드, 본인 + SUPER_ADMIN + 비활성 자동 제외.
   - 다이얼로그 열 때 한 번만 load (openDialog 시 loadCandidates).
   - 빈 결과/로딩 placeholder 처리.
This commit is contained in:
2026-05-12 17:02:15 +09:00
parent af23fd0316
commit c4a62b7e35
4 changed files with 64 additions and 13 deletions
@@ -53,7 +53,7 @@ public class SubstituteController {
@PathVariable("id") Long substituteId,
@RequestAttribute("company_code") String companyCode,
@RequestAttribute("role") String role) {
if (!"ADMIN".equals(role) && !"SUPER_ADMIN".equals(role)) {
if (!"ADMIN".equals(role) && !"COMPANY_ADMIN".equals(role) && !"SUPER_ADMIN".equals(role)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error("관리자만 조회할 수 있습니다."));
}
@@ -239,7 +239,7 @@ public class SubstituteService extends BaseService {
private void requireAdmin(Map<String, Object> params) {
String role = (String) params.get("role");
if (!"ADMIN".equals(role) && !"SUPER_ADMIN".equals(role)) {
if (!"ADMIN".equals(role) && !"COMPANY_ADMIN".equals(role) && !"SUPER_ADMIN".equals(role)) {
throw new AccessDeniedException("관리자만 대무자를 지정/수정/해지할 수 있습니다.");
}
}
@@ -222,7 +222,7 @@
AND L.COMPANY_CODE = R.COMPANY_CODE
)
</if>
ORDER BY R.CREATED_DATE DESC
ORDER BY R.CREATED_AT DESC
<if test="page_limit != null">
LIMIT #{page_limit} OFFSET #{page_offset}
</if>
@@ -465,7 +465,7 @@
SELECT L.*,
R.TITLE, R.TARGET_TABLE, R.TARGET_RECORD_ID,
R.REQUESTER_NAME, R.REQUESTER_DEPT,
R.CREATED_DATE AS REQUEST_CREATED_DATE
R.CREATED_AT AS REQUEST_CREATED_DATE
FROM APPROVAL_LINES L
JOIN APPROVAL_REQUESTS R
ON L.REQUEST_ID = R.REQUEST_ID AND L.COMPANY_CODE = R.COMPANY_CODE
@@ -475,7 +475,7 @@
</foreach>
AND L.STATUS = 'pending'
AND (L.COMPANY_CODE = #{company_code} OR L.COMPANY_CODE = '*')
ORDER BY R.CREATED_DATE ASC
ORDER BY R.CREATED_AT ASC
</select>
<!-- ================================================================
@@ -6,12 +6,20 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
getSubstituteList,
createSubstitute,
deleteSubstitute,
checkSubstituteOverlap,
} from "@/lib/api/substitute";
import { getUserList } from "@/lib/api/user";
/**
* 사용자별 대무자(代務者) 관리 섹션.
@@ -39,6 +47,10 @@ export function SubstituteSection({ originalUserId, originalUserName }: Substitu
const [dialogOpen, setDialogOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
// 같은 회사 활성 사용자 목록 (대무자 후보) — 본인/SUPER_ADMIN/비활성 제외
const [candidates, setCandidates] = useState<Record<string, any>[]>([]);
const [candidatesLoading, setCandidatesLoading] = useState(false);
const [form, setForm] = useState({
proxy_user_id: "",
start_date: "",
@@ -63,10 +75,29 @@ export function SubstituteSection({ originalUserId, originalUserName }: Substitu
load();
}, [load]);
// 대무자 후보 사용자 목록 로드 (다이얼로그 열릴 때 한번)
const loadCandidates = useCallback(async () => {
setCandidatesLoading(true);
const res = await getUserList({ status: "active", limit: 1000 });
if (res?.success && Array.isArray(res.data?.list)) {
const filtered = (res.data.list as Record<string, any>[]).filter(
(u) =>
u.user_id !== originalUserId &&
u.user_type !== "SUPER_ADMIN" &&
u.status === "active",
);
setCandidates(filtered);
} else {
setCandidates([]);
}
setCandidatesLoading(false);
}, [originalUserId]);
const openDialog = () => {
setForm({ proxy_user_id: "", start_date: "", end_date: "", reason: "" });
setError(null);
setDialogOpen(true);
loadCandidates();
};
const submit = async () => {
@@ -215,16 +246,36 @@ export function SubstituteSection({ originalUserId, originalUserName }: Substitu
<div className="space-y-3 py-2 text-sm">
<div>
<Label htmlFor="proxy_user_id"> ID</Label>
<Input
id="proxy_user_id"
<Label htmlFor="proxy_user_id"></Label>
<Select
value={form.proxy_user_id}
onChange={(e) => setForm({ ...form, proxy_user_id: e.target.value })}
placeholder="예: hjkim"
autoFocus
/>
onValueChange={(v) => setForm({ ...form, proxy_user_id: v })}
>
<SelectTrigger id="proxy_user_id" autoFocus>
<SelectValue
placeholder={
candidatesLoading
? "사용자 목록 불러오는 중..."
: candidates.length === 0
? "지정 가능한 사용자가 없습니다"
: "대무자를 선택하세요"
}
/>
</SelectTrigger>
<SelectContent>
{candidates.map((u) => (
<SelectItem key={u.user_id} value={u.user_id}>
{u.user_name || u.user_id}
<span className="ml-1 text-muted-foreground">
({u.user_id}
{u.dept_name ? ` · ${u.dept_name}` : ""})
</span>
</SelectItem>
))}
</SelectContent>
</Select>
<p className="mt-1 text-[0.7rem] text-muted-foreground">
. SUPER_ADMIN .
. ·SUPER_ADMIN .
</p>
</div>