Merge pull request 'fix(대무자): COMPANY_ADMIN + SQL + UI select' from johngreen into main
Build & Deploy to K8s / build-and-deploy (push) Failing after 3m24s

This commit is contained in:
2026-05-12 17:02:44 +09:00
4 changed files with 64 additions and 13 deletions
@@ -53,7 +53,7 @@ public class SubstituteController {
@PathVariable("id") Long substituteId, @PathVariable("id") Long substituteId,
@RequestAttribute("company_code") String companyCode, @RequestAttribute("company_code") String companyCode,
@RequestAttribute("role") String role) { @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) return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error("관리자만 조회할 수 있습니다.")); .body(ApiResponse.error("관리자만 조회할 수 있습니다."));
} }
@@ -239,7 +239,7 @@ public class SubstituteService extends BaseService {
private void requireAdmin(Map<String, Object> params) { private void requireAdmin(Map<String, Object> params) {
String role = (String) params.get("role"); 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("관리자만 대무자를 지정/수정/해지할 수 있습니다."); throw new AccessDeniedException("관리자만 대무자를 지정/수정/해지할 수 있습니다.");
} }
} }
@@ -222,7 +222,7 @@
AND L.COMPANY_CODE = R.COMPANY_CODE AND L.COMPANY_CODE = R.COMPANY_CODE
) )
</if> </if>
ORDER BY R.CREATED_DATE DESC ORDER BY R.CREATED_AT DESC
<if test="page_limit != null"> <if test="page_limit != null">
LIMIT #{page_limit} OFFSET #{page_offset} LIMIT #{page_limit} OFFSET #{page_offset}
</if> </if>
@@ -465,7 +465,7 @@
SELECT L.*, SELECT L.*,
R.TITLE, R.TARGET_TABLE, R.TARGET_RECORD_ID, R.TITLE, R.TARGET_TABLE, R.TARGET_RECORD_ID,
R.REQUESTER_NAME, R.REQUESTER_DEPT, R.REQUESTER_NAME, R.REQUESTER_DEPT,
R.CREATED_DATE AS REQUEST_CREATED_DATE R.CREATED_AT AS REQUEST_CREATED_DATE
FROM APPROVAL_LINES L FROM APPROVAL_LINES L
JOIN APPROVAL_REQUESTS R JOIN APPROVAL_REQUESTS R
ON L.REQUEST_ID = R.REQUEST_ID AND L.COMPANY_CODE = R.COMPANY_CODE ON L.REQUEST_ID = R.REQUEST_ID AND L.COMPANY_CODE = R.COMPANY_CODE
@@ -475,7 +475,7 @@
</foreach> </foreach>
AND L.STATUS = 'pending' AND L.STATUS = 'pending'
AND (L.COMPANY_CODE = #{company_code} OR L.COMPANY_CODE = '*') AND (L.COMPANY_CODE = #{company_code} OR L.COMPANY_CODE = '*')
ORDER BY R.CREATED_DATE ASC ORDER BY R.CREATED_AT ASC
</select> </select>
<!-- ================================================================ <!-- ================================================================
@@ -6,12 +6,20 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { import {
getSubstituteList, getSubstituteList,
createSubstitute, createSubstitute,
deleteSubstitute, deleteSubstitute,
checkSubstituteOverlap, checkSubstituteOverlap,
} from "@/lib/api/substitute"; } 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 [dialogOpen, setDialogOpen] = useState(false);
const [submitting, setSubmitting] = 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({ const [form, setForm] = useState({
proxy_user_id: "", proxy_user_id: "",
start_date: "", start_date: "",
@@ -63,10 +75,29 @@ export function SubstituteSection({ originalUserId, originalUserName }: Substitu
load(); load();
}, [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 = () => { const openDialog = () => {
setForm({ proxy_user_id: "", start_date: "", end_date: "", reason: "" }); setForm({ proxy_user_id: "", start_date: "", end_date: "", reason: "" });
setError(null); setError(null);
setDialogOpen(true); setDialogOpen(true);
loadCandidates();
}; };
const submit = async () => { const submit = async () => {
@@ -215,16 +246,36 @@ export function SubstituteSection({ originalUserId, originalUserName }: Substitu
<div className="space-y-3 py-2 text-sm"> <div className="space-y-3 py-2 text-sm">
<div> <div>
<Label htmlFor="proxy_user_id"> ID</Label> <Label htmlFor="proxy_user_id"></Label>
<Input <Select
id="proxy_user_id"
value={form.proxy_user_id} value={form.proxy_user_id}
onChange={(e) => setForm({ ...form, proxy_user_id: e.target.value })} onValueChange={(v) => setForm({ ...form, proxy_user_id: v })}
placeholder="예: hjkim" >
autoFocus <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"> <p className="mt-1 text-[0.7rem] text-muted-foreground">
. SUPER_ADMIN . . ·SUPER_ADMIN .
</p> </p>
</div> </div>