Skip to content

Commit 33482bf

Browse files
ImTotemclaude
andcommitted
feat: 비기너 지원 페이지 + DynamicForm + 지원 API 레이어
- Application 타입 정의 (FormTemplate, MyApplication, RecruitmentPeriod 등) - GraphQL 쿼리/뮤테이션 + React Query 훅 - DynamicForm: 질문 타입별 렌더 (short_text, long_text, multiple_choice, checkbox) - ApplyPage: 조건 분기 (활동 중/이미 지원/모집기간 아님/지원 가능) - ApplyLayout: 사이드바 없는 인증 필요 레이아웃 - /apply 라우트 추가 - shadcn textarea 컴포넌트 설치 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 4b4f6ce commit 33482bf

8 files changed

Lines changed: 613 additions & 0 deletions

File tree

src/App.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import { MemberDetailPage } from "@/pages/members/MemberDetailPage";
1010
import { LinksPage } from "@/pages/links/LinksPage";
1111
import { QrPage } from "@/pages/qr/QrPage";
1212
import { ExpiredPage } from "@/pages/expired/ExpiredPage";
13+
import { ApplyLayout } from "@/components/layout/ApplyLayout";
14+
import { ApplyPage } from "@/pages/apply/ApplyPage";
1315
import { Toaster } from "@/components/ui/sonner";
1416

1517
export default function App() {
@@ -29,6 +31,11 @@ export default function App() {
2931
<Route path="/qr" element={<QrPage />} />
3032
</Route>
3133
</Route>
34+
<Route element={<ProtectedRoute />}>
35+
<Route element={<ApplyLayout />}>
36+
<Route path="/apply" element={<ApplyPage />} />
37+
</Route>
38+
</Route>
3239
<Route path="/expired" element={<ExpiredPage />} />
3340
<Route path="/" element={<Navigate to="/members" replace />} />
3441
<Route path="*" element={<Navigate to="/" replace />} />

src/api/applications.ts

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { gql } from "graphql-request";
2+
import { gqlClient } from "./graphql-client";
3+
import type {
4+
FormTemplate,
5+
MyApplication,
6+
RecruitmentPeriod,
7+
ApplicationSubmission,
8+
ApplicationListItem,
9+
} from "@/types/application";
10+
import type { PagedResponse } from "@/types/common";
11+
12+
const MY_APPLICATION_QUERY = gql`
13+
query MyApplication {
14+
myApplication {
15+
id status formTemplateId track submittedAt
16+
answers { questionId value }
17+
paymentInfo { bank account amount holder }
18+
}
19+
}
20+
`;
21+
22+
const FORM_TEMPLATE_QUERY = gql`
23+
query FormTemplate($type: String!) {
24+
formTemplate(type: $type) {
25+
id type updatedAt
26+
questions { id type label required options order }
27+
}
28+
}
29+
`;
30+
31+
const RECRUITMENT_PERIOD_QUERY = gql`
32+
query RecruitmentPeriod($type: String!) {
33+
recruitmentPeriod(type: $type) {
34+
id type startDate endDate isActive
35+
}
36+
}
37+
`;
38+
39+
const SUBMIT_APPLICATION = gql`
40+
mutation SubmitApplication($input: ApplicationSubmissionInput!) {
41+
submitApplication(input: $input) { id status }
42+
}
43+
`;
44+
45+
const CANCEL_APPLICATION = gql`
46+
mutation CancelApplication($id: ID!) {
47+
cancelApplication(id: $id) { id status }
48+
}
49+
`;
50+
51+
const APPLICATIONS_QUERY = gql`
52+
query Applications($filter: ApplicationFilterInput) {
53+
applications(filter: $filter) {
54+
items { id applicantName applicantEmail track status submittedAt }
55+
total page size
56+
}
57+
}
58+
`;
59+
60+
const APPROVE_APPLICATION = gql`
61+
mutation ApproveApplication($id: ID!) {
62+
approveApplication(id: $id) { id status }
63+
}
64+
`;
65+
66+
const BATCH_APPROVE = gql`
67+
mutation BatchApproveApplications($ids: [ID!]!) {
68+
batchApproveApplications(ids: $ids) { count }
69+
}
70+
`;
71+
72+
export async function getMyApplication(): Promise<MyApplication | null> {
73+
const data = await gqlClient.request<{ myApplication: MyApplication | null }>(
74+
MY_APPLICATION_QUERY,
75+
);
76+
return data.myApplication;
77+
}
78+
79+
export async function getFormTemplate(type: string): Promise<FormTemplate> {
80+
const data = await gqlClient.request<{ formTemplate: FormTemplate }>(
81+
FORM_TEMPLATE_QUERY,
82+
{ type },
83+
);
84+
return data.formTemplate;
85+
}
86+
87+
export async function getRecruitmentPeriod(type: string): Promise<RecruitmentPeriod | null> {
88+
const data = await gqlClient.request<{ recruitmentPeriod: RecruitmentPeriod | null }>(
89+
RECRUITMENT_PERIOD_QUERY,
90+
{ type },
91+
);
92+
return data.recruitmentPeriod;
93+
}
94+
95+
export async function submitApplication(input: ApplicationSubmission): Promise<MyApplication> {
96+
const data = await gqlClient.request<{ submitApplication: MyApplication }>(
97+
SUBMIT_APPLICATION,
98+
{ input },
99+
);
100+
return data.submitApplication;
101+
}
102+
103+
export async function cancelApplication(id: string): Promise<MyApplication> {
104+
const data = await gqlClient.request<{ cancelApplication: MyApplication }>(
105+
CANCEL_APPLICATION,
106+
{ id },
107+
);
108+
return data.cancelApplication;
109+
}
110+
111+
export async function getApplications(
112+
filter: Record<string, unknown>,
113+
): Promise<PagedResponse<ApplicationListItem>> {
114+
const data = await gqlClient.request<{ applications: PagedResponse<ApplicationListItem> }>(
115+
APPLICATIONS_QUERY,
116+
{ filter },
117+
);
118+
return data.applications;
119+
}
120+
121+
export async function approveApplication(id: string): Promise<MyApplication> {
122+
const data = await gqlClient.request<{ approveApplication: MyApplication }>(
123+
APPROVE_APPLICATION,
124+
{ id },
125+
);
126+
return data.approveApplication;
127+
}
128+
129+
export async function batchApproveApplications(ids: string[]): Promise<{ count: number }> {
130+
const data = await gqlClient.request<{ batchApproveApplications: { count: number } }>(
131+
BATCH_APPROVE,
132+
{ ids },
133+
);
134+
return data.batchApproveApplications;
135+
}
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import { useState } from "react";
2+
import { Input } from "@/components/ui/input";
3+
import { Textarea } from "@/components/ui/textarea";
4+
import { Label } from "@/components/ui/label";
5+
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
6+
import { Checkbox } from "@/components/ui/checkbox";
7+
import { Button } from "@/components/ui/button";
8+
import {
9+
Select,
10+
SelectContent,
11+
SelectItem,
12+
SelectTrigger,
13+
SelectValue,
14+
} from "@/components/ui/select";
15+
import type { FormQuestion, ApplicationAnswer } from "@/types/application";
16+
17+
interface FixedFields {
18+
name: string;
19+
email: string;
20+
schoolEmail: string;
21+
track: string;
22+
}
23+
24+
interface DynamicFormProps {
25+
questions: FormQuestion[];
26+
fixedFields: FixedFields;
27+
trackOptions: string[];
28+
onSubmit: (answers: ApplicationAnswer[], track: string) => void;
29+
isPending: boolean;
30+
submitLabel?: string;
31+
}
32+
33+
export function DynamicForm({
34+
questions,
35+
fixedFields,
36+
trackOptions,
37+
onSubmit,
38+
isPending,
39+
submitLabel = "제출",
40+
}: DynamicFormProps) {
41+
const [track, setTrack] = useState(fixedFields.track);
42+
const [answers, setAnswers] = useState<Record<string, string | string[]>>(() => {
43+
const init: Record<string, string | string[]> = {};
44+
for (const q of questions) {
45+
init[q.id] = q.type === "checkbox" ? [] : "";
46+
}
47+
return init;
48+
});
49+
50+
const sorted = [...questions].sort((a, b) => a.order - b.order);
51+
52+
const setAnswer = (questionId: string, value: string | string[]) => {
53+
setAnswers((prev) => ({ ...prev, [questionId]: value }));
54+
};
55+
56+
const toggleCheckbox = (questionId: string, option: string) => {
57+
setAnswers((prev) => {
58+
const current = prev[questionId] as string[];
59+
const next = current.includes(option)
60+
? current.filter((v) => v !== option)
61+
: [...current, option];
62+
return { ...prev, [questionId]: next };
63+
});
64+
};
65+
66+
const isValid = () => {
67+
if (!track) return false;
68+
for (const q of sorted) {
69+
if (!q.required) continue;
70+
const val = answers[q.id];
71+
if (Array.isArray(val) ? val.length === 0 : !val) return false;
72+
}
73+
return true;
74+
};
75+
76+
const handleSubmit = (e: React.FormEvent) => {
77+
e.preventDefault();
78+
const result: ApplicationAnswer[] = sorted.map((q) => ({
79+
questionId: q.id,
80+
value: answers[q.id],
81+
}));
82+
onSubmit(result, track);
83+
};
84+
85+
return (
86+
<form onSubmit={handleSubmit} className="space-y-6">
87+
<div className="space-y-4 rounded-lg border p-4">
88+
<h3 className="text-sm font-medium text-muted-foreground">기본 정보</h3>
89+
<div className="space-y-2">
90+
<Label>이름</Label>
91+
<Input value={fixedFields.name} disabled className="bg-muted" />
92+
</div>
93+
<div className="space-y-2">
94+
<Label>이메일</Label>
95+
<Input value={fixedFields.email} disabled className="bg-muted" />
96+
</div>
97+
<div className="space-y-2">
98+
<Label>학교 이메일</Label>
99+
<Input value={fixedFields.schoolEmail} disabled className="bg-muted" />
100+
</div>
101+
<div className="space-y-2">
102+
<Label>희망 트랙</Label>
103+
<Select value={track} onValueChange={(v) => setTrack(v ?? "")}>
104+
<SelectTrigger>
105+
<SelectValue placeholder="트랙 선택" />
106+
</SelectTrigger>
107+
<SelectContent>
108+
{trackOptions.map((t) => (
109+
<SelectItem key={t} value={t}>{t}</SelectItem>
110+
))}
111+
</SelectContent>
112+
</Select>
113+
</div>
114+
</div>
115+
116+
{sorted.map((q) => (
117+
<div key={q.id} className="space-y-2">
118+
<Label>
119+
{q.label}
120+
{q.required && <span className="ml-1 text-destructive">*</span>}
121+
</Label>
122+
123+
{q.type === "short_text" && (
124+
<Input
125+
value={answers[q.id] as string}
126+
onChange={(e) => setAnswer(q.id, e.target.value)}
127+
/>
128+
)}
129+
130+
{q.type === "long_text" && (
131+
<Textarea
132+
value={answers[q.id] as string}
133+
onChange={(e) => setAnswer(q.id, e.target.value)}
134+
rows={4}
135+
/>
136+
)}
137+
138+
{q.type === "multiple_choice" && q.options && (
139+
<RadioGroup
140+
value={answers[q.id] as string}
141+
onValueChange={(v) => setAnswer(q.id, v)}
142+
>
143+
{q.options.map((opt) => (
144+
<div key={opt} className="flex items-center space-x-2">
145+
<RadioGroupItem value={opt} id={`${q.id}-${opt}`} />
146+
<Label htmlFor={`${q.id}-${opt}`} className="cursor-pointer font-normal">
147+
{opt}
148+
</Label>
149+
</div>
150+
))}
151+
</RadioGroup>
152+
)}
153+
154+
{q.type === "checkbox" && q.options && (
155+
<div className="space-y-2">
156+
{q.options.map((opt) => (
157+
<label key={opt} className="flex cursor-pointer items-center gap-2">
158+
<Checkbox
159+
checked={(answers[q.id] as string[]).includes(opt)}
160+
onCheckedChange={() => toggleCheckbox(q.id, opt)}
161+
/>
162+
<span className="text-sm">{opt}</span>
163+
</label>
164+
))}
165+
</div>
166+
)}
167+
</div>
168+
))}
169+
170+
<Button type="submit" className="w-full" disabled={!isValid() || isPending}>
171+
{isPending ? "제출 중..." : submitLabel}
172+
</Button>
173+
</form>
174+
);
175+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { Outlet } from "react-router-dom";
2+
3+
export function ApplyLayout() {
4+
return (
5+
<div className="flex min-h-svh items-start justify-center bg-background px-4 py-12">
6+
<div className="w-full max-w-2xl">
7+
<Outlet />
8+
</div>
9+
</div>
10+
);
11+
}

src/components/ui/textarea.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import * as React from "react"
2+
3+
import { cn } from "@/lib/utils"
4+
5+
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
6+
return (
7+
<textarea
8+
data-slot="textarea"
9+
className={cn(
10+
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
11+
className
12+
)}
13+
{...props}
14+
/>
15+
)
16+
}
17+
18+
export { Textarea }

0 commit comments

Comments
 (0)