@@ -16,9 +16,20 @@ interface CodeSelectProps extends React.ComponentPropsWithoutRef<typeof Select>
|
||||
useYn?: string;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
/** 레이블(label htmlFor) 연결용 트리거 id */
|
||||
id?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
function CodeSelect({ upCd, useYn, placeholder = "선택", className, ...props }: CodeSelectProps) {
|
||||
function CodeSelect({
|
||||
upCd,
|
||||
useYn,
|
||||
placeholder = "선택",
|
||||
className,
|
||||
id,
|
||||
title,
|
||||
...props
|
||||
}: CodeSelectProps) {
|
||||
const listQuery = useCodeList(useYn ? undefined : upCd);
|
||||
const listByUseYnQuery = useCodeListByUseYn(useYn ? upCd : undefined, useYn ?? "");
|
||||
|
||||
@@ -27,7 +38,7 @@ function CodeSelect({ upCd, useYn, placeholder = "선택", className, ...props }
|
||||
|
||||
return (
|
||||
<Select {...props} disabled={props.disabled || isLoading}>
|
||||
<SelectTrigger className={cn("w-[12rem]", className)}>
|
||||
<SelectTrigger id={id} title={title} className={cn("w-[12rem]", className)}>
|
||||
<SelectValue placeholder={placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
||||
@@ -84,9 +84,9 @@ function CklstArtclFormDialog({
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>사용여부</Label>
|
||||
<Label htmlFor="cklstArtclUseYn">사용여부</Label>
|
||||
<Select value={useYn} onValueChange={(v) => setValue("useYn", v as "Y" | "N")}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectTrigger id="cklstArtclUseYn" className="w-full">
|
||||
<SelectValue placeholder="사용여부" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
||||
@@ -100,8 +100,9 @@ function CklstMngFormDialog({
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>문서구분</Label>
|
||||
<Label htmlFor="cklstDocSeComCd">문서구분</Label>
|
||||
<CodeSelect
|
||||
id="cklstDocSeComCd"
|
||||
upCd="ASDOCSE"
|
||||
value={docSeComCd}
|
||||
onValueChange={(v) => setValue("docSeComCd", v)}
|
||||
@@ -110,8 +111,9 @@ function CklstMngFormDialog({
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>업무구분</Label>
|
||||
<Label htmlFor="cklstTaskSeComCd">업무구분</Label>
|
||||
<CodeSelect
|
||||
id="cklstTaskSeComCd"
|
||||
upCd={docSeComCd || "ASDOCSE"}
|
||||
value={watch("taskSeComCd")}
|
||||
onValueChange={(v) => setValue("taskSeComCd", v)}
|
||||
@@ -121,9 +123,9 @@ function CklstMngFormDialog({
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>사용여부</Label>
|
||||
<Label htmlFor="cklstUseYn">사용여부</Label>
|
||||
<Select value={useYn} onValueChange={(v) => setValue("useYn", v as "Y" | "N")}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectTrigger id="cklstUseYn" className="w-full">
|
||||
<SelectValue placeholder="사용여부" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
||||
@@ -44,14 +44,23 @@ function CklstPreviewDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[64rem]">
|
||||
<DialogContent className="flex max-h-[90vh] max-w-[64rem] flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>체크리스트 미리보기 — {cklstNm}</DialogTitle>
|
||||
</DialogHeader>
|
||||
{displayRows.length === 0 ? (
|
||||
<p className="py-6 text-center text-muted-foreground">표시할 항목이 없습니다.</p>
|
||||
) : (
|
||||
<Table caption={`체크리스트 미리보기 ${cklstNm} 목록으로 점검항목, 주요 검토내용, 검토 여부 정보 제공`}>
|
||||
// 항목이 많아 창 높이를 넘으면 표 영역만 스크롤 (초점을 받아 키보드로도 스크롤 가능)
|
||||
<div
|
||||
tabIndex={0}
|
||||
role="region"
|
||||
aria-label="체크리스트 미리보기 목록"
|
||||
className="min-h-0 flex-1 overflow-y-auto rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
||||
>
|
||||
<Table
|
||||
caption={`체크리스트 미리보기 ${cklstNm} 목록으로 점검항목, 주요 검토내용, 검토 여부 정보 제공`}
|
||||
>
|
||||
<colgroup>
|
||||
<col width="25%" />
|
||||
<col width="50%" />
|
||||
@@ -84,7 +93,10 @@ function CklstPreviewDialog({
|
||||
<TableCell>{row.rvwCn ?? "—"}</TableCell>
|
||||
<TableCell align="center">
|
||||
{showRadios ? (
|
||||
<RadioGroup className="flex items-center justify-center">
|
||||
<RadioGroup
|
||||
aria-label="검토 여부"
|
||||
className="flex items-center justify-center"
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<RadioGroupItem value="Y" id={`${row.rowKey}-preview-y`} />
|
||||
<Label htmlFor={`${row.rowKey}-preview-y`}>예</Label>
|
||||
@@ -103,6 +115,7 @@ function CklstPreviewDialog({
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -289,13 +289,13 @@ function CklstRvwGrid({ selectedCklst }: CklstRvwGridProps) {
|
||||
</colgroup>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>점검항목</TableHead>
|
||||
<TableHead>주요 검토내용</TableHead>
|
||||
<TableHead>사용 여부</TableHead>
|
||||
<TableHead>체크 사용</TableHead>
|
||||
<TableHead>정렬 순서</TableHead>
|
||||
<TableHead>구분</TableHead>
|
||||
<TableHead>관리</TableHead>
|
||||
<TableHead scope="col">점검항목</TableHead>
|
||||
<TableHead scope="col">주요 검토내용</TableHead>
|
||||
<TableHead scope="col">사용 여부</TableHead>
|
||||
<TableHead scope="col">체크 사용</TableHead>
|
||||
<TableHead scope="col">정렬 순서</TableHead>
|
||||
<TableHead scope="col">구분</TableHead>
|
||||
<TableHead scope="col">관리</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
|
||||
@@ -55,7 +55,11 @@ function CklstRvwRowComponent({
|
||||
onValueChange={(v) => onChange({ cklstArtclSn: Number(v), isDirty: true })}
|
||||
disabled={!row.isNew}
|
||||
>
|
||||
<SelectTrigger className="w-full flex-1">
|
||||
{/* 비활성(기존 항목) 상태에서도 텍스트 명도 대비 4.5:1 이상 유지 */}
|
||||
<SelectTrigger
|
||||
title="점검항목 선택"
|
||||
className="w-full flex-1 disabled:opacity-100"
|
||||
>
|
||||
<SelectValue placeholder="점검항목 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -73,12 +77,14 @@ function CklstRvwRowComponent({
|
||||
value={row.rvwCn ?? ""}
|
||||
onChange={(e) => onChange({ rvwCn: e.target.value, isDirty: true })}
|
||||
placeholder="주요 검토내용"
|
||||
title="주요 검토내용"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<RadioGroup
|
||||
value={row.useYn ?? "Y"}
|
||||
onValueChange={(v) => onChange({ useYn: v as "Y" | "N", isDirty: true })}
|
||||
aria-label="사용 여부"
|
||||
className="flex justify-center gap-4"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -95,6 +101,7 @@ function CklstRvwRowComponent({
|
||||
<RadioGroup
|
||||
value={row.bchckUseYn ?? "Y"}
|
||||
onValueChange={(v) => onChange({ bchckUseYn: v as "Y" | "N", isDirty: true })}
|
||||
aria-label="체크 사용"
|
||||
className="flex justify-center gap-4"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -111,6 +118,7 @@ function CklstRvwRowComponent({
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
title="정렬 순서"
|
||||
value={row.sortSeq ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
|
||||
@@ -79,6 +79,17 @@ function getBadgeColor(state: BadgeState) {
|
||||
}
|
||||
}
|
||||
|
||||
function getBadgeStateText(state: BadgeState) {
|
||||
switch (state) {
|
||||
case "completed":
|
||||
return "완료";
|
||||
case "current":
|
||||
return "진행중";
|
||||
case "pending":
|
||||
return "대기";
|
||||
}
|
||||
}
|
||||
|
||||
function formatOptional(value: string | null | undefined) {
|
||||
return value?.trim() ? value : "-";
|
||||
}
|
||||
@@ -95,7 +106,9 @@ function ProgressStepBadge({ prgrsSttsComCd }: ProgressStepBadgeProps) {
|
||||
{badges.map((badge, index) => (
|
||||
<Fragment key={`${badge.label}-${index}`}>
|
||||
{index > 0 && <span className="mx-2">></span>}
|
||||
<Badge color={getBadgeColor(badge.state)}>{badge.label}</Badge>
|
||||
<Badge color={getBadgeColor(badge.state)}>
|
||||
{badge.label}({getBadgeStateText(badge.state)})
|
||||
</Badge>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -120,6 +120,17 @@ function getBadgeColor(state: BadgeState) {
|
||||
}
|
||||
}
|
||||
|
||||
function getBadgeStateText(state: BadgeState) {
|
||||
switch (state) {
|
||||
case "completed":
|
||||
return "완료";
|
||||
case "current":
|
||||
return "진행중";
|
||||
case "pending":
|
||||
return "대기";
|
||||
}
|
||||
}
|
||||
|
||||
function formatOptional(value: string | null | undefined) {
|
||||
return value?.trim() ? value : "-";
|
||||
}
|
||||
@@ -137,7 +148,9 @@ function ProgressStepBadge({ prgrsSttsComCd, techRplyHstryYn }: ProgressStepBadg
|
||||
{badges.map((badge, index) => (
|
||||
<Fragment key={badge.label}>
|
||||
{index > 0 && <span className="mx-2">></span>}
|
||||
<Badge color={getBadgeColor(badge.state)}>{badge.label}</Badge>
|
||||
<Badge color={getBadgeColor(badge.state)}>
|
||||
{badge.label}({getBadgeStateText(badge.state)})
|
||||
</Badge>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -121,6 +121,17 @@ function getBadgeColor(state: BadgeState) {
|
||||
}
|
||||
}
|
||||
|
||||
function getBadgeStateText(state: BadgeState) {
|
||||
switch (state) {
|
||||
case "completed":
|
||||
return "완료";
|
||||
case "current":
|
||||
return "진행중";
|
||||
case "pending":
|
||||
return "대기";
|
||||
}
|
||||
}
|
||||
|
||||
function formatOptional(value: string | null | undefined) {
|
||||
return value?.trim() ? value : "-";
|
||||
}
|
||||
@@ -138,7 +149,9 @@ function ProgressStepBadge({ prgrsSttsComCd, techRplyHstryYn }: ProgressStepBadg
|
||||
{badges.map((badge, index) => (
|
||||
<Fragment key={badge.label}>
|
||||
{index > 0 && <span className="mx-2">></span>}
|
||||
<Badge color={getBadgeColor(badge.state)}>{badge.label}</Badge>
|
||||
<Badge color={getBadgeColor(badge.state)}>
|
||||
{badge.label}({getBadgeStateText(badge.state)})
|
||||
</Badge>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useConfirm } from "@frontend-opensource/use-react-hooks";
|
||||
import { AxiosError } from "axios";
|
||||
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
import type { RvwMnpwMngResponseDto } from "@/entities/rvw-mnpw";
|
||||
@@ -10,6 +11,14 @@ import { Badge, Button } from "@common/components";
|
||||
|
||||
import type { RvwMnpwBlockConfig } from "../model/rvw-mnpw-block-config";
|
||||
|
||||
/** 서버 오류 응답의 message 를 우선 표시하고, 없으면 기본 문구를 사용한다 */
|
||||
function getErrorMessage(error: unknown, fallback: string) {
|
||||
if (error instanceof AxiosError) {
|
||||
return error.response?.data?.message || fallback;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
interface RvwMnpwBlockProps {
|
||||
block: RvwMnpwBlockConfig;
|
||||
items: RvwMnpwMngResponseDto[];
|
||||
@@ -33,8 +42,7 @@ function RvwMnpwBlock({ block, items, selectedPerson }: RvwMnpwBlockProps) {
|
||||
});
|
||||
alert("배치되었습니다.");
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "배치에 실패했습니다.";
|
||||
alert(message);
|
||||
alert(getErrorMessage(error, "배치에 실패했습니다."));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -48,8 +56,7 @@ function RvwMnpwBlock({ block, items, selectedPerson }: RvwMnpwBlockProps) {
|
||||
userUuid: item.userUuid,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "제거에 실패했습니다.";
|
||||
alert(message);
|
||||
alert(getErrorMessage(error, "제거에 실패했습니다."));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -119,6 +119,17 @@ function getBadgeColor(state: BadgeState) {
|
||||
}
|
||||
}
|
||||
|
||||
function getBadgeStateText(state: BadgeState) {
|
||||
switch (state) {
|
||||
case "completed":
|
||||
return "완료";
|
||||
case "current":
|
||||
return "진행중";
|
||||
case "pending":
|
||||
return "대기";
|
||||
}
|
||||
}
|
||||
|
||||
function formatOptional(value: string | undefined) {
|
||||
return value?.trim() ? value : "-";
|
||||
}
|
||||
@@ -136,7 +147,9 @@ function ProgressStepBadge({ prgrsSttsComCd, techRplyHstryYn }: ProgressStepBadg
|
||||
{badges.map((badge, index) => (
|
||||
<Fragment key={`${badge.label}-${index}`}>
|
||||
{index > 0 && <span className="mx-2">></span>}
|
||||
<Badge color={getBadgeColor(badge.state)}>{badge.label}</Badge>
|
||||
<Badge color={getBadgeColor(badge.state)}>
|
||||
{badge.label}({getBadgeStateText(badge.state)})
|
||||
</Badge>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -120,6 +120,7 @@ function TmplntDtlInput({ facilityIndex, planStpNo, fieldKey, decimals }: Tmplnt
|
||||
|
||||
interface TmplntFacilityMatrixProps {
|
||||
swtrmtFcltCd: string;
|
||||
facilityName?: string;
|
||||
sido: string;
|
||||
gugun: string;
|
||||
facilityIndex: number;
|
||||
@@ -129,6 +130,7 @@ interface TmplntFacilityMatrixProps {
|
||||
|
||||
function TmplntFacilityMatrix({
|
||||
swtrmtFcltCd,
|
||||
facilityName,
|
||||
sido,
|
||||
gugun,
|
||||
facilityIndex,
|
||||
@@ -147,7 +149,10 @@ function TmplntFacilityMatrix({
|
||||
const originalData = Array.isArray(data) ? (data[0] ?? null) : null;
|
||||
|
||||
return (
|
||||
<Table caption="처리시설별 하수도정비기본계획 목록으로 구분, 당초, 현재, 변경 정보 제공" variant="secondary">
|
||||
<Table
|
||||
caption={`${facilityName ? `${facilityName} ` : ""}처리시설별 하수도정비기본계획 목록으로 구분, 당초, 현재, 변경 정보 제공`}
|
||||
variant="secondary"
|
||||
>
|
||||
<colgroup>
|
||||
<col width="12%" />
|
||||
<col width="9.75%" />
|
||||
@@ -317,7 +322,10 @@ function TmplntFacilityAttachments({
|
||||
return (
|
||||
<div className="mt-6 space-y-2">
|
||||
<h3 className="mb-4 text-xl font-semibold">처리구역 필수 첨부파일</h3>
|
||||
<Table caption="처리구역 필수 첨부파일 목록으로 파일명, 파일 확장자, 파일첨부 정보 제공" variant="secondary">
|
||||
<Table
|
||||
caption={`${facilityName ? `${facilityName} ` : ""}처리구역 필수 첨부파일 목록으로 파일명, 파일 확장자, 파일첨부 정보 제공`}
|
||||
variant="secondary"
|
||||
>
|
||||
<colgroup>
|
||||
<col width="40%" />
|
||||
<col width="15%" />
|
||||
@@ -481,6 +489,8 @@ function TmplntFormSection() {
|
||||
<TabsTrigger
|
||||
key={field.id}
|
||||
value={field.swtrmtFcltCd}
|
||||
id={`tmplnt-form-tab-${field.swtrmtFcltCd}`}
|
||||
aria-controls={`tmplnt-form-panel-${field.swtrmtFcltCd}`}
|
||||
className="px-6 py-3 text-2xl"
|
||||
>
|
||||
{facilityName}
|
||||
@@ -497,10 +507,14 @@ function TmplntFormSection() {
|
||||
<TabsContent
|
||||
key={field.id}
|
||||
value={field.swtrmtFcltCd}
|
||||
id={`tmplnt-form-panel-${field.swtrmtFcltCd}`}
|
||||
aria-labelledby={`tmplnt-form-tab-${field.swtrmtFcltCd}`}
|
||||
className="mt-0 border-0 p-0 shadow-none"
|
||||
>
|
||||
<h3 className="sr-only">{facilityName} 하수도정비기본계획</h3>
|
||||
<TmplntFacilityMatrix
|
||||
swtrmtFcltCd={field.swtrmtFcltCd}
|
||||
facilityName={facilityName}
|
||||
sido={sido ?? ""}
|
||||
gugun={gugun ?? ""}
|
||||
facilityIndex={facilityIndex}
|
||||
|
||||
@@ -77,6 +77,7 @@ function getDtlValue(
|
||||
|
||||
interface TmplntFacilityMatrixProps {
|
||||
item: SewerBscPlanTmplntResponseDto;
|
||||
facilityName?: string;
|
||||
sido?: string;
|
||||
gugun?: string;
|
||||
crtrYr?: string;
|
||||
@@ -85,6 +86,7 @@ interface TmplntFacilityMatrixProps {
|
||||
|
||||
function TmplntFacilityMatrix({
|
||||
item,
|
||||
facilityName,
|
||||
sido,
|
||||
gugun,
|
||||
crtrYr,
|
||||
@@ -102,7 +104,10 @@ function TmplntFacilityMatrix({
|
||||
const originalData = Array.isArray(data) ? (data[0] ?? null) : null;
|
||||
|
||||
return (
|
||||
<Table caption="처리시설별 하수도정비기본계획 목록으로 구분, 당초, 현재, 변경 정보 제공" variant="secondary">
|
||||
<Table
|
||||
caption={`${facilityName ? `${facilityName} ` : ""}처리시설별 하수도정비기본계획 목록으로 구분, 당초, 현재, 변경 정보 제공`}
|
||||
variant="secondary"
|
||||
>
|
||||
<colgroup>
|
||||
<col width="12%" />
|
||||
<col width="9.75%" />
|
||||
@@ -217,6 +222,8 @@ function TmplntSection({ tmplntList, det, dtl }: TmplntSectionProps) {
|
||||
<TabsTrigger
|
||||
key={item.swtrmtFcltCd}
|
||||
value={item.swtrmtFcltCd ?? ""}
|
||||
id={`tmplnt-view-tab-${item.swtrmtFcltCd}`}
|
||||
aria-controls={`tmplnt-view-panel-${item.swtrmtFcltCd}`}
|
||||
className="!px-6 !py-3 text-xl"
|
||||
>
|
||||
{facilityName}
|
||||
@@ -228,10 +235,17 @@ function TmplntSection({ tmplntList, det, dtl }: TmplntSectionProps) {
|
||||
{tmplntList.map((item) => {
|
||||
const facilityName = resolveFacilityName(item, facilityOptions);
|
||||
return (
|
||||
<TabsContent key={item.swtrmtFcltCd} value={item.swtrmtFcltCd ?? ""}>
|
||||
<TabsContent
|
||||
key={item.swtrmtFcltCd}
|
||||
value={item.swtrmtFcltCd ?? ""}
|
||||
id={`tmplnt-view-panel-${item.swtrmtFcltCd}`}
|
||||
aria-labelledby={`tmplnt-view-tab-${item.swtrmtFcltCd}`}
|
||||
>
|
||||
<h3 className="sr-only">{facilityName} 하수도정비기본계획</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<TmplntFacilityMatrix
|
||||
item={item}
|
||||
facilityName={facilityName}
|
||||
sido={det.sido}
|
||||
gugun={det.gugun}
|
||||
crtrYr={dtl?.crtrYr}
|
||||
@@ -242,7 +256,10 @@ function TmplntSection({ tmplntList, det, dtl }: TmplntSectionProps) {
|
||||
{(item.atchFileGroupId || item.atchFileInfo?.orgnlFileNm) && (
|
||||
<div className="mt-6 space-y-2">
|
||||
<h3 className="mb-4 text-xl font-semibold">처리구역 필수 첨부파일</h3>
|
||||
<Table caption="처리구역 필수 첨부파일 목록으로 파일명, 파일첨부 정보 제공" variant="secondary">
|
||||
<Table
|
||||
caption={`${facilityName ? `${facilityName} ` : ""}처리구역 필수 첨부파일 목록으로 파일명, 파일첨부 정보 제공`}
|
||||
variant="secondary"
|
||||
>
|
||||
<colgroup>
|
||||
<col width="35%" />
|
||||
<col width="65%" />
|
||||
|
||||
@@ -127,7 +127,9 @@ function SewerBscPlanApplyDialog({ open, onOpenChange, onConfirm }: SewerBscPlan
|
||||
<TableHeader>
|
||||
{canSidoGugunSelectable && (
|
||||
<TableRow>
|
||||
<TableHead scope="row" data-required>시도</TableHead>
|
||||
<TableHead scope="row" data-required>
|
||||
<label htmlFor="sewer-bsc-plan-apply-sido">시도</label>
|
||||
</TableHead>
|
||||
<TableCell>
|
||||
<Select
|
||||
value={sidoComCd || undefined}
|
||||
@@ -136,7 +138,12 @@ function SewerBscPlanApplyDialog({ open, onOpenChange, onConfirm }: SewerBscPlan
|
||||
setSggComCd("");
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectTrigger
|
||||
id="sewer-bsc-plan-apply-sido"
|
||||
title="시도 선택"
|
||||
aria-required="true"
|
||||
className="w-full"
|
||||
>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -148,14 +155,16 @@ function SewerBscPlanApplyDialog({ open, onOpenChange, onConfirm }: SewerBscPlan
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableHead scope="row">시군구</TableHead>
|
||||
<TableHead scope="row">
|
||||
<label htmlFor="sewer-bsc-plan-apply-sgg">시군구</label>
|
||||
</TableHead>
|
||||
<TableCell>
|
||||
<Select
|
||||
value={sggComCd || undefined}
|
||||
onValueChange={setSggComCd}
|
||||
disabled={!sidoComCd || isUserGugunPending}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectTrigger id="sewer-bsc-plan-apply-sgg" title="시군구 선택" className="w-full">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -170,12 +179,17 @@ function SewerBscPlanApplyDialog({ open, onOpenChange, onConfirm }: SewerBscPlan
|
||||
</TableRow>
|
||||
)}
|
||||
<TableRow>
|
||||
<TableHead scope="row" data-required>계획구분</TableHead>
|
||||
<TableHead id="sewer-bsc-plan-apply-plan-se-label" scope="row" data-required>
|
||||
계획구분
|
||||
</TableHead>
|
||||
<TableCell colSpan={3}>
|
||||
<FieldSet className="px-2">
|
||||
{/* 라디오 그룹이 어떤 항목의 선택인지 알 수 있도록 th 와 연결 */}
|
||||
<RadioGroup
|
||||
value={sewerBscPlanSeComCd || undefined}
|
||||
onValueChange={setSewerBscPlanSeComCd}
|
||||
aria-labelledby="sewer-bsc-plan-apply-plan-se-label"
|
||||
aria-required="true"
|
||||
>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{planSeOptions?.map((option) => (
|
||||
@@ -199,10 +213,17 @@ function SewerBscPlanApplyDialog({ open, onOpenChange, onConfirm }: SewerBscPlan
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead scope="row" data-required>업무구분</TableHead>
|
||||
<TableHead id="sewer-bsc-plan-apply-task-se-label" scope="row" data-required>
|
||||
업무구분
|
||||
</TableHead>
|
||||
<TableCell colSpan={3}>
|
||||
<FieldSet className="px-2">
|
||||
<RadioGroup value={taskSeComCd || undefined} onValueChange={setTaskSeComCd}>
|
||||
<RadioGroup
|
||||
value={taskSeComCd || undefined}
|
||||
onValueChange={setTaskSeComCd}
|
||||
aria-labelledby="sewer-bsc-plan-apply-task-se-label"
|
||||
aria-required="true"
|
||||
>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{taskSeOptions?.map((option) => (
|
||||
<FieldLabel
|
||||
|
||||
@@ -13,6 +13,9 @@ import {
|
||||
|
||||
import { type SelectedOgnzUser, toSelectedOgnzUser } from "../model/selected-ognz-user";
|
||||
|
||||
const FOCUS_RING_CLASS_NAME =
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring";
|
||||
|
||||
interface OgnzUserTreeNodeProps {
|
||||
node: OgnzUserTreeResponseDto;
|
||||
depth?: number;
|
||||
@@ -56,7 +59,10 @@ function OgnzUserTreeNode({
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="group flex w-full items-center gap-2 rounded-sm p-2 text-left hover:bg-accent"
|
||||
className={cn(
|
||||
"group flex w-full items-center gap-2 rounded-sm p-2 text-left hover:bg-accent",
|
||||
FOCUS_RING_CLASS_NAME,
|
||||
)}
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
@@ -73,20 +79,41 @@ function OgnzUserTreeNode({
|
||||
{node.users.map((user) => {
|
||||
const isSelected = selectedUserId === user.userUnqId;
|
||||
const selectedUser = toSelectedOgnzUser(user, node);
|
||||
const rowClassName = cn(
|
||||
"group flex w-full cursor-pointer items-center gap-2 rounded-sm p-2 text-left hover:bg-accent",
|
||||
isSelected && "!border !border-primary !bg-primary-transparent",
|
||||
);
|
||||
const userLabel = (
|
||||
<>
|
||||
<span className="text-[1.4rem] text-label">{user.userNm}</span>
|
||||
<span className="text-xl text-muted-foreground">({user.jbpsSeNm})</span>
|
||||
</>
|
||||
);
|
||||
|
||||
// 선택 버튼이 없으면 행 자체를 버튼으로 렌더링해 키보드(Tab/Enter/Space)로 선택 가능하게 한다.
|
||||
if (!showSelectButton) {
|
||||
return (
|
||||
<button
|
||||
key={user.userUnqId}
|
||||
type="button"
|
||||
style={{ paddingInlineStart: indent + 24 }}
|
||||
className={cn(rowClassName, FOCUS_RING_CLASS_NAME)}
|
||||
aria-pressed={isSelected}
|
||||
onClick={() => onSelect(selectedUser)}
|
||||
>
|
||||
{userLabel}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={user.userUnqId}
|
||||
style={{ paddingInlineStart: indent + 24 }}
|
||||
className={cn(
|
||||
"group flex cursor-pointer items-center gap-2 rounded-sm p-2 hover:bg-accent",
|
||||
isSelected && "!border !border-primary !bg-primary-transparent",
|
||||
)}
|
||||
className={rowClassName}
|
||||
onClick={() => onSelect(selectedUser)}
|
||||
>
|
||||
<span className="text-[1.4rem] text-label">{user.userNm}</span>
|
||||
<span className="text-xl text-muted-foreground">({user.jbpsSeNm})</span>
|
||||
{showSelectButton ? (
|
||||
{userLabel}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
@@ -97,7 +124,6 @@ function OgnzUserTreeNode({
|
||||
>
|
||||
선택
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -115,7 +115,9 @@ function PbadmsTaskApplyDialog({
|
||||
<TableHeader>
|
||||
{canSidoGugunSelectable && (
|
||||
<TableRow>
|
||||
<TableHead scope="row" data-required>시도</TableHead>
|
||||
<TableHead scope="row" data-required>
|
||||
<label htmlFor="pbadms-task-apply-sido">시도</label>
|
||||
</TableHead>
|
||||
<TableCell>
|
||||
<Select
|
||||
value={sidoComCd || undefined}
|
||||
@@ -124,7 +126,12 @@ function PbadmsTaskApplyDialog({
|
||||
setSggComCd("");
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectTrigger
|
||||
id="pbadms-task-apply-sido"
|
||||
title="시도 선택"
|
||||
aria-required="true"
|
||||
className="w-full"
|
||||
>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -136,14 +143,16 @@ function PbadmsTaskApplyDialog({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableHead scope="row">시군구</TableHead>
|
||||
<TableHead scope="row">
|
||||
<label htmlFor="pbadms-task-apply-sgg">시군구</label>
|
||||
</TableHead>
|
||||
<TableCell>
|
||||
<Select
|
||||
value={sggComCd || undefined}
|
||||
onValueChange={setSggComCd}
|
||||
disabled={!sidoComCd || isUserGugunPending}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectTrigger id="pbadms-task-apply-sgg" title="시군구 선택" className="w-full">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -158,9 +167,12 @@ function PbadmsTaskApplyDialog({
|
||||
</TableRow>
|
||||
)}
|
||||
<TableRow>
|
||||
<TableHead scope="row" data-required>업무구분</TableHead>
|
||||
<TableHead id="pbadms-task-apply-task-label" scope="row" data-required>
|
||||
업무구분
|
||||
</TableHead>
|
||||
<TableCell colSpan={3}>
|
||||
<FieldSet className="px-2">
|
||||
{/* 체크박스 그룹이 어떤 항목의 선택인지 알 수 있도록 th 와 연결 */}
|
||||
<FieldSet className="px-2" aria-labelledby="pbadms-task-apply-task-label">
|
||||
<FieldGroup data-slot="checkbox-group" className="flex-row flex-wrap">
|
||||
{taskOptions?.map((option) => (
|
||||
<Field key={option.lwpoCd} orientation="horizontal" className="w-auto">
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useRef, type MouseEvent } from "react";
|
||||
import { useEffect, useRef, useState, type MouseEvent } from "react";
|
||||
import Autoplay from "embla-carousel-autoplay";
|
||||
import type { UseEmblaCarouselType } from "embla-carousel-react";
|
||||
import { Pause, Play } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ENDPOINT } from "../../config";
|
||||
import { getSiteInfo, getBannerList, getImageUrlByAtflCd } from "../../api";
|
||||
@@ -80,6 +82,31 @@ export function Footer() {
|
||||
bannerList?.filter((banner) => banner.bnrTypeCd === "BNRFTER") ?? [];
|
||||
|
||||
const plugin = useRef(Autoplay({ delay: 2000, stopOnInteraction: true }));
|
||||
const [carouselApi, setCarouselApi] = useState<UseEmblaCarouselType[1]>();
|
||||
const [isAutoplaying, setIsAutoplaying] = useState(true);
|
||||
|
||||
// 사용자 조작·초점 진입 등으로 자동 넘김이 멈추거나 재개될 때 버튼 상태를 동기화한다
|
||||
useEffect(() => {
|
||||
if (!carouselApi) return;
|
||||
|
||||
const syncAutoplayState = () => setIsAutoplaying(plugin.current.isPlaying());
|
||||
syncAutoplayState();
|
||||
carouselApi.on("autoplay:play", syncAutoplayState);
|
||||
carouselApi.on("autoplay:stop", syncAutoplayState);
|
||||
|
||||
return () => {
|
||||
carouselApi.off("autoplay:play", syncAutoplayState);
|
||||
carouselApi.off("autoplay:stop", syncAutoplayState);
|
||||
};
|
||||
}, [carouselApi]);
|
||||
|
||||
const handleAutoplayToggle = () => {
|
||||
if (plugin.current.isPlaying()) {
|
||||
plugin.current.stop();
|
||||
} else {
|
||||
plugin.current.play();
|
||||
}
|
||||
};
|
||||
|
||||
const handlePopupLinkClick = (event: MouseEvent<HTMLAnchorElement>) => {
|
||||
event.preventDefault();
|
||||
@@ -89,9 +116,10 @@ export function Footer() {
|
||||
return (
|
||||
<footer className="flex w-full shrink-0 flex-col items-start border-t border-solid border-border bg-background px-3 pb-1 pt-4 text-foreground sm:flex-row sm:px-6">
|
||||
<div className="mb-4 w-full sm:mb-0 sm:w-[55%]">
|
||||
<h6 className="mb-2 text-lg font-bold sm:mb-4 sm:text-xl md:text-2xl">
|
||||
{/* 제목 수준 건너뜀(h3 → h6) 방지: 사이트명은 섹션 제목이 아니므로 p 사용 */}
|
||||
<p className="mb-2 text-lg font-bold sm:mb-4 sm:text-xl md:text-2xl">
|
||||
{siteInfo?.siteNm ?? ""}
|
||||
</h6>
|
||||
</p>
|
||||
<p className="my-1 text-sm opacity-60 sm:text-base md:text-xl">
|
||||
{siteInfo?.siteAddr ?? ""}
|
||||
</p>
|
||||
@@ -132,10 +160,26 @@ export function Footer() {
|
||||
>지원센타</a>
|
||||
</div>
|
||||
<div className="hidden sm:block">
|
||||
<div className="mt-4 flex justify-end sm:mt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAutoplayToggle}
|
||||
aria-label={isAutoplaying ? "배너 자동 넘김 멈춤" : "배너 자동 넘김 시작"}
|
||||
className="flex items-center gap-1 rounded-sm border border-border bg-background px-2 py-1 text-sm text-accent-foreground hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
{isAutoplaying ? (
|
||||
<Pause className="size-4" aria-hidden="true" />
|
||||
) : (
|
||||
<Play className="size-4" aria-hidden="true" />
|
||||
)}
|
||||
<span>{isAutoplaying ? "멈춤" : "시작"}</span>
|
||||
</button>
|
||||
</div>
|
||||
<Carousel
|
||||
setApi={setCarouselApi}
|
||||
plugins={[plugin.current]}
|
||||
opts={{ loop: true }}
|
||||
className="relative my-4 h-20 sm:my-8 sm:h-24"
|
||||
className="relative mb-4 mt-2 h-20 sm:mb-8 sm:h-24"
|
||||
>
|
||||
<CarouselContent>
|
||||
{mainBanners && mainBanners.length > 0 ? (
|
||||
|
||||
@@ -292,7 +292,11 @@ export function Header({
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Skeleton className="h-[3rem] w-[15rem]" />
|
||||
// h1 안에는 div(Skeleton)를 둘 수 없어 인라인 요소로 로딩 자리표시
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="block h-[3rem] w-[15rem] animate-pulse rounded-md bg-[rgba(0,0,0,0.1)]"
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className="text-[1.2rem] font-semibold text-foreground"
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "../dialog";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "../dialog";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getMenuDetail } from "../../api";
|
||||
import { ENDPOINT } from "../../config";
|
||||
@@ -24,7 +30,6 @@ const PageTitle = ({
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex w-full items-center justify-between px-[1.8rem] pt-[2rem]">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-[2.4rem] font-semibold text-title">{title}</h2>
|
||||
@@ -43,9 +48,13 @@ const PageTitle = ({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 도움말 다이얼로그: DialogTrigger 로 연결해야 닫을 때 키보드 초점이 도움말 버튼으로 복귀한다 */}
|
||||
<Dialog open={isHelpDialogOpen} onOpenChange={setIsHelpDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<button
|
||||
onClick={() => setIsHelpDialogOpen(true)}
|
||||
className="flex cursor-pointer items-center gap-3 transition-opacity hover:opacity-80"
|
||||
type="button"
|
||||
className="flex cursor-pointer items-center gap-3 rounded-sm transition-opacity hover:opacity-80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
<img
|
||||
src="/images/help.png"
|
||||
@@ -53,16 +62,18 @@ const PageTitle = ({
|
||||
className="h-[20px] w-[20px]"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 도움말 다이얼로그 */}
|
||||
<Dialog open={isHelpDialogOpen} onOpenChange={setIsHelpDialogOpen}>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="flex max-h-[90vh] flex-col sm:max-w-[85rem]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>도움말</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto py-4">
|
||||
{/* 스크롤 영역이 초점을 받을 수 있어야 키보드(Tab/방향키)로 내용 이동·스크롤이 가능하다 */}
|
||||
<div
|
||||
tabIndex={0}
|
||||
role="region"
|
||||
aria-label="도움말 내용"
|
||||
className="min-h-0 flex-1 overflow-y-auto rounded-sm py-4 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
||||
>
|
||||
{menuDetail?.menuHlpCn ? (
|
||||
<div
|
||||
style={{
|
||||
@@ -89,7 +100,8 @@ const PageTitle = ({
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user