Skip to content

Commit 5efc4cd

Browse files
committed
feat(core-attendance): 인증 API 연동 + 팀 목록 불러오기 및 필터 적용
1 parent 46c92d9 commit 5efc4cd

File tree

1 file changed

+139
-119
lines changed

1 file changed

+139
-119
lines changed

src/app/core-attendance/page.jsx

Lines changed: 139 additions & 119 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,13 @@ export default function AttendancePage() {
2222

2323
// data
2424
const [dates, setDates] = useState([]);
25-
const [members, setMembers] = useState([]); // [{userId,name,team,present,...}]
25+
const [teams, setTeams] = useState([]); // [{ id, name, lead? }]
26+
const [members, setMembers] = useState([]); // [{userId,name,team(=라벨),present,...}]
2627
const [summary, setSummary] = useState(null);
2728

2829
// UI
2930
const [filter, setFilter] = useState('');
30-
const [teamFilter, setTeamFilter] = useState(''); // 클라 사이드 팀 필터
31+
const [teamFilter, setTeamFilter] = useState(''); // 팀 라벨(=members[].team) 기준 필터
3132
const [presentSet, setPresentSet] = useState(new Set());
3233
const [dirty, setDirty] = useState(false);
3334

@@ -38,12 +39,16 @@ export default function AttendancePage() {
3839
addDate: async (d) => (await apiClient.post('/core-attendance/meetings', {date: d})).data.data,
3940
deleteDate: async (d) => (await apiClient.delete(`/core-attendance/meetings/${d}`)).data.data,
4041

42+
// Teams
43+
getTeams: async () => (await apiClient.get('/core-attendance/meetings/teams')).data.data,
44+
4145
// Members (전체 팀 포함)
4246
getMembers: async (d) => (await apiClient.get(`/core-attendance/meetings/${d}/members`)).data.data,
4347

4448
// Batch save
4549
saveAttendance: async (d, userIds, present) => (await apiClient.put(`/core-attendance/meetings/${d}/attendance`, {
46-
userIds, present,
50+
userIds,
51+
present
4752
})).data.data,
4853

4954
// Summary(옵션)
@@ -69,6 +74,21 @@ export default function AttendancePage() {
6974
// eslint-disable-next-line react-hooks/exhaustive-deps
7075
}, []);
7176

77+
/** 팀 로드 (리드=본인 팀만 / 관리자=전체) */
78+
useEffect(() => {
79+
(async () => {
80+
try {
81+
const list = await api.getTeams();
82+
setTeams(Array.isArray(list) ? list : []);
83+
// 선택된 필터가 없고, 서버가 1개만 보내줬다면 자동 선택(리드인 경우 UX)
84+
if (!teamFilter && list?.length === 1) setTeamFilter(list[0].name);
85+
} catch {
86+
setTeams([]);
87+
}
88+
})();
89+
// eslint-disable-next-line react-hooks/exhaustive-deps
90+
}, []);
91+
7292
/** 선택 날짜 → 멤버/출석 로드 */
7393
useEffect(() => {
7494
(async () => {
@@ -101,9 +121,10 @@ export default function AttendancePage() {
101121
})();
102122
}, [date]); // eslint-disable-line react-hooks/exhaustive-deps
103123

104-
// 클라 필터링
105-
const teamOptions = useMemo(() => Array.from(new Set(members.map((m) => m.team))).filter(Boolean), [members]);
124+
// 팀 옵션: 서버에서 내려준 팀 라벨(name) 사용 (members[].team과 동일한 라벨로 필터링)
125+
const teamOptions = useMemo(() => Array.from(new Set(teams.map((t) => t.name))).filter(Boolean), [teams]);
106126

127+
// 클라 필터링
107128
const filteredMembers = useMemo(() => {
108129
let base = members;
109130
if (teamFilter) base = base.filter((m) => m.team === teamFilter);
@@ -199,129 +220,128 @@ export default function AttendancePage() {
199220
};
200221

201222
return (<div className="flex flex-col max-w-[1100px] mx-auto min-h-[100svh] py-16 px-6">
202-
<h1 className="font-bold mb-6 text-4xl tablet:text-3xl mobile:text-2xl">출석 관리</h1>
223+
<h1 className="font-bold mb-6 text-4xl tablet:text-3xl mobile:text-2xl">출석 관리</h1>
224+
225+
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
226+
{/* 날짜 */}
227+
<Card>
228+
<CardBody className="gap-3">
229+
<div className="flex items-center justify-between">
230+
<b>날짜</b>
231+
<Button size="sm" color="primary" onPress={addToday}>
232+
오늘 추가
233+
</Button>
234+
</div>
203235

204-
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
205-
{/* 날짜 */}
206-
<Card>
207-
<CardBody className="gap-3">
208-
<div className="flex items-center justify-between">
209-
<b>날짜</b>
210-
<Button size="sm" color="primary" onPress={addToday}>
211-
오늘 추가
212-
</Button>
213-
</div>
236+
<Input type="date" value={date} onChange={(e) => setDate(e.target.value)}/>
237+
<Divider/>
238+
<div className="max-h-[180px] overflow-auto space-y-2">
239+
{dates.map((d) => (<div key={d} className="flex items-center justify-between">
240+
<Button size="sm" variant="light" onPress={() => setDate(d)}>
241+
{d === date ? <b>{d}</b> : d}
242+
</Button>
243+
<Button size="sm" color="danger" variant="flat" onPress={() => removeDate(d)}>
244+
삭제
245+
</Button>
246+
</div>))}
247+
{dates.length === 0 && (<div className="text-sm text-foreground-500">등록된 날짜가 없습니다.</div>)}
248+
</div>
249+
</CardBody>
250+
</Card>
251+
252+
{/* 필터 & 저장 */}
253+
<Card>
254+
<CardBody className="gap-3">
255+
<div className="flex items-center justify-between">
256+
<b>필터 / 저장</b>
257+
<Button
258+
size="sm"
259+
color="primary"
260+
variant="flat"
261+
onPress={saveSnapshot}
262+
isDisabled={!dirty || !members.length}
263+
>
264+
저장{dirty ? ' *' : ''}
265+
</Button>
266+
</div>
214267

215-
{/* date picker는 유지(빠른 변경용) */}
216-
<Input type="date" value={date} onChange={(e) => setDate(e.target.value)}/>
217-
<Divider/>
218-
<div className="max-h-[180px] overflow-auto space-y-2">
219-
{dates.map((d) => (<div key={d} className="flex items-center justify-between">
220-
<Button size="sm" variant="light" onPress={() => setDate(d)}>
221-
{d === date ? <b>{d}</b> : d}
268+
<Select
269+
label="팀(클라이언트 필터)"
270+
selectedKeys={teamFilter ? new Set([teamFilter]) : new Set()}
271+
onSelectionChange={(keys) => {
272+
const first = String(Array.from(keys || [])[0] ?? '');
273+
setTeamFilter(first || '');
274+
}}
275+
variant="bordered"
276+
>
277+
{teamOptions.map((name) => (<SelectItem key={name} value={name}>
278+
{name}
279+
</SelectItem>))}
280+
</Select>
281+
282+
<Input placeholder="이름 검색" value={filter} onValueChange={setFilter} size="sm"/>
283+
284+
<div className="flex gap-2">
285+
<Button size="sm" onPress={() => checkAll(true)} color="success" variant="flat">
286+
(필터된) 전체 체크
222287
</Button>
223-
<Button size="sm" color="danger" variant="flat" onPress={() => removeDate(d)}>
224-
삭제
288+
<Button size="sm" onPress={() => checkAll(false)} color="warning" variant="flat">
289+
(필터된) 전체 해제
225290
</Button>
226-
</div>))}
227-
{dates.length === 0 && (<div className="text-sm text-foreground-500">등록된 날짜가 없습니다.</div>)}
228-
</div>
229-
</CardBody>
230-
</Card>
231-
232-
{/* 필터 & 저장 */}
233-
<Card>
291+
</div>
292+
</CardBody>
293+
</Card>
294+
295+
{/* 요약 */}
296+
<Card>
297+
<CardBody className="gap-3">
298+
<b>요약</b>
299+
{summary ? (<div className="text-sm">
300+
<div className="mb-2">전체 {summary.present} / {summary.total}</div>
301+
<Divider/>
302+
<div className="mt-2 space-y-1">
303+
{summary.perTeam.map((ts) => (
304+
<div key={ts.teamId} className="flex items-center justify-between">
305+
<span>{ts.teamName}</span>
306+
<span>{ts.present} / {ts.total}</span>
307+
</div>))}
308+
</div>
309+
</div>) : (<div className="text-foreground-500 text-sm">로딩...</div>)}
310+
</CardBody>
311+
</Card>
312+
</div>
313+
314+
{/* 팀원 목록 */}
315+
<Card className="mt-6">
234316
<CardBody className="gap-3">
235317
<div className="flex items-center justify-between">
236-
<b>필터 / 저장</b>
237-
<Button
238-
size="sm"
239-
color="primary"
240-
variant="flat"
241-
onPress={saveSnapshot}
242-
isDisabled={!dirty || !members.length}
243-
>
244-
저장{dirty ? ' *' : ''}
245-
</Button>
318+
<div>
319+
<b>팀원</b>
320+
<div className="text-xs text-foreground-500">
321+
{date} · {teamFilter || '전체 팀'}
322+
</div>
323+
</div>
246324
</div>
247325

248-
<Select
249-
label="팀(클라이언트 필터)"
250-
selectedKeys={teamFilter ? new Set([teamFilter]) : new Set()}
251-
onSelectionChange={(keys) => {
252-
const first = String(Array.from(keys || [])[0] ?? '');
253-
setTeamFilter(first || '');
254-
}}
255-
variant="bordered"
256-
>
257-
{teamOptions.map((t) => (<SelectItem key={t} value={t}>
258-
{t}
259-
</SelectItem>))}
260-
</Select>
261-
262-
<Input placeholder="이름 검색" value={filter} onValueChange={setFilter} size="sm"/>
263-
264-
<div className="flex gap-2">
265-
<Button size="sm" onPress={() => checkAll(true)} color="success" variant="flat">
266-
(필터된) 전체 체크
267-
</Button>
268-
<Button size="sm" onPress={() => checkAll(false)} color="warning" variant="flat">
269-
(필터된) 전체 해제
270-
</Button>
271-
</div>
272-
</CardBody>
273-
</Card>
326+
<Divider/>
274327

275-
{/* 요약 */}
276-
<Card>
277-
<CardBody className="gap-3">
278-
<b>요약</b>
279-
{summary ? (<div className="text-sm">
280-
<div className="mb-2">전체 {summary.present} / {summary.total}</div>
281-
<Divider/>
282-
<div className="mt-2 space-y-1">
283-
{summary.perTeam.map((ts) => (
284-
<div key={ts.teamId} className="flex items-center justify-between">
285-
<span>{ts.teamName}</span>
286-
<span>{ts.present} / {ts.total}</span>
287-
</div>))}
288-
</div>
289-
</div>) : (<div className="text-foreground-500 text-sm">로딩...</div>)}
328+
<div className="max-h-[460px] overflow-auto">
329+
{filteredMembers.map((m) => {
330+
const id = String(m.userId);
331+
const checked = presentSet.has(id);
332+
return (<div key={id} className="flex items-center justify-between py-2">
333+
<div className="flex items-center gap-3">
334+
<Checkbox isSelected={checked} onValueChange={() => toggleMember(m)}>
335+
{m.name}{' '}
336+
<span className="text-xs text-foreground-500 ml-2">({m.team})</span>
337+
</Checkbox>
338+
</div>
339+
</div>);
340+
})}
341+
{filteredMembers.length === 0 && (
342+
<div className="text-sm text-foreground-500 py-3">표시할 팀원이 없습니다.</div>)}
343+
</div>
290344
</CardBody>
291345
</Card>
292-
</div>
293-
294-
{/* 팀원 목록 */}
295-
<Card className="mt-6">
296-
<CardBody className="gap-3">
297-
<div className="flex items-center justify-between">
298-
<div>
299-
<b>팀원</b>
300-
<div className="text-xs text-foreground-500">
301-
{date} · {teamFilter || '전체 팀'}
302-
</div>
303-
</div>
304-
</div>
305-
306-
<Divider/>
307-
308-
<div className="max-h-[460px] overflow-auto">
309-
{filteredMembers.map((m) => {
310-
const id = String(m.userId);
311-
const checked = presentSet.has(id);
312-
return (<div key={id} className="flex items-center justify-between py-2">
313-
<div className="flex items-center gap-3">
314-
<Checkbox isSelected={checked} onValueChange={() => toggleMember(m)}>
315-
{m.name}{' '}
316-
<span className="text-xs text-foreground-500 ml-2">({m.team})</span>
317-
</Checkbox>
318-
</div>
319-
</div>);
320-
})}
321-
{filteredMembers.length === 0 && (
322-
<div className="text-sm text-foreground-500 py-3">표시할 팀원이 없습니다.</div>)}
323-
</div>
324-
</CardBody>
325-
</Card>
326-
</div>);
346+
</div>);
327347
}

0 commit comments

Comments
 (0)