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:
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user