careermate 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +256 -0
- package/THIRD_PARTY_NOTICES.md +40 -0
- package/apps/mcp/src/index.ts +66 -0
- package/apps/web/DESIGN_GUIDE.md +105 -0
- package/apps/web/UI_CONTRACT.md +44 -0
- package/apps/web/public/app.js +118 -0
- package/apps/web/public/fonts/PretendardVariable.woff2 +0 -0
- package/apps/web/public/index.html +41 -0
- package/apps/web/public/lib.js +282 -0
- package/apps/web/public/pages/applications.js +98 -0
- package/apps/web/public/pages/documents.js +446 -0
- package/apps/web/public/pages/home.js +263 -0
- package/apps/web/public/pages/interview.js +230 -0
- package/apps/web/public/pages/jobs.js +494 -0
- package/apps/web/public/pages/profile.js +576 -0
- package/apps/web/public/pages/settings.js +233 -0
- package/apps/web/public/styles.css +426 -0
- package/apps/web/src/exports.ts +68 -0
- package/apps/web/src/http.ts +180 -0
- package/apps/web/src/index.ts +49 -0
- package/apps/web/src/info.ts +50 -0
- package/apps/web/src/routes.ts +350 -0
- package/apps/web/src/security.ts +102 -0
- package/apps/web/src/server.ts +141 -0
- package/apps/web/src/settings.ts +88 -0
- package/bin/careermate.mjs +74 -0
- package/dist/careermate.mcpb +0 -0
- package/dist/install-page/index.html +474 -0
- package/dist/install-page/style.css +391 -0
- package/dist/install-page/vercel.json +20 -0
- package/dist/mcp-smoke.err +3 -0
- package/dist/mcp.mjs +23704 -0
- package/dist/mcpb-stage/README.md +219 -0
- package/dist/mcpb-stage/dist/install-page/index.html +434 -0
- package/dist/mcpb-stage/dist/install-page/style.css +407 -0
- package/dist/mcpb-stage/dist/install-page/vercel.json +20 -0
- package/dist/mcpb-stage/dist/mcp.mjs +23704 -0
- package/dist/mcpb-stage/dist/public/app.js +118 -0
- package/dist/mcpb-stage/dist/public/fonts/PretendardVariable.woff2 +0 -0
- package/dist/mcpb-stage/dist/public/index.html +41 -0
- package/dist/mcpb-stage/dist/public/lib.js +282 -0
- package/dist/mcpb-stage/dist/public/pages/applications.js +98 -0
- package/dist/mcpb-stage/dist/public/pages/documents.js +446 -0
- package/dist/mcpb-stage/dist/public/pages/home.js +263 -0
- package/dist/mcpb-stage/dist/public/pages/interview.js +230 -0
- package/dist/mcpb-stage/dist/public/pages/jobs.js +494 -0
- package/dist/mcpb-stage/dist/public/pages/profile.js +576 -0
- package/dist/mcpb-stage/dist/public/pages/settings.js +233 -0
- package/dist/mcpb-stage/dist/public/styles.css +420 -0
- package/dist/mcpb-stage/dist/web.mjs +7240 -0
- package/dist/mcpb-stage/manifest.json +40 -0
- package/dist/public/app.js +118 -0
- package/dist/public/fonts/PretendardVariable.woff2 +0 -0
- package/dist/public/index.html +41 -0
- package/dist/public/lib.js +282 -0
- package/dist/public/pages/applications.js +98 -0
- package/dist/public/pages/documents.js +446 -0
- package/dist/public/pages/home.js +263 -0
- package/dist/public/pages/interview.js +230 -0
- package/dist/public/pages/jobs.js +494 -0
- package/dist/public/pages/profile.js +576 -0
- package/dist/public/pages/settings.js +233 -0
- package/dist/public/styles.css +426 -0
- package/dist/web.mjs +7240 -0
- package/docs/ARCHITECTURE.md +208 -0
- package/docs/CHANGES_V1.md +103 -0
- package/docs/DATA_MODEL.md +460 -0
- package/docs/DECISIONS.md +277 -0
- package/docs/DEMO.md +242 -0
- package/docs/INSTALL.md +148 -0
- package/docs/INSTALL_AND_USAGE.md +99 -0
- package/docs/MCP_TOOLS.md +233 -0
- package/docs/ROADMAP.md +134 -0
- package/docs/START_WORKFLOW.md +125 -0
- package/docs/SUPPORTED_AI_APPS.md +60 -0
- package/docs/TODO.md +57 -0
- package/docs/UX_NOTES.md +247 -0
- package/docs/WORKFLOWS.md +200 -0
- package/install-page/index.html +474 -0
- package/install-page/style.css +391 -0
- package/install-page/vercel.json +20 -0
- package/package.json +68 -0
- package/packages/core/src/context.ts +74 -0
- package/packages/core/src/index.ts +8 -0
- package/packages/core/src/onboarding.ts +81 -0
- package/packages/core/src/services.ts +146 -0
- package/packages/core/src/summary.ts +104 -0
- package/packages/db/src/connection.ts +46 -0
- package/packages/db/src/index.ts +22 -0
- package/packages/db/src/paths.ts +41 -0
- package/packages/db/src/repositories.ts +828 -0
- package/packages/db/src/runtime.ts +58 -0
- package/packages/db/src/schema.ts +189 -0
- package/packages/exporters/src/html.ts +113 -0
- package/packages/exporters/src/index.ts +364 -0
- package/packages/exporters/src/markdown.ts +178 -0
- package/packages/mcp-tools/src/bridge.ts +83 -0
- package/packages/mcp-tools/src/index.ts +8 -0
- package/packages/mcp-tools/src/result.ts +49 -0
- package/packages/mcp-tools/src/tools.ts +455 -0
- package/packages/parsers/src/html.ts +86 -0
- package/packages/parsers/src/index.ts +228 -0
- package/packages/parsers/src/keywords.ts +151 -0
- package/packages/prompts/src/humanize.ts +59 -0
- package/packages/prompts/src/index.ts +82 -0
- package/packages/prompts/src/install.ts +43 -0
- package/packages/prompts/src/onboarding.ts +35 -0
- package/packages/prompts/src/system.ts +53 -0
- package/packages/shared/src/enums.ts +103 -0
- package/packages/shared/src/index.ts +18 -0
- package/packages/shared/src/schemas.ts +398 -0
- package/packages/workflows/src/definitions.ts +107 -0
- package/packages/workflows/src/index.ts +39 -0
- package/scripts/build-dist.mjs +62 -0
- package/scripts/build-mcpb.mjs +70 -0
- package/scripts/doctor.ts +81 -0
- package/scripts/init.ts +342 -0
- package/scripts/mcp-probe.ts +55 -0
- package/scripts/migrate.ts +6 -0
- package/scripts/run.mjs +33 -0
- package/scripts/seed.ts +129 -0
- package/scripts/test.ts +117 -0
- package/scripts/ui-smoke.ts +73 -0
- package/tsconfig.json +29 -0
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
// Profile — the user's career identity, fully editable. Feeds AI writing/analysis.
|
|
2
|
+
import {
|
|
3
|
+
el, get, post, put, del, icon, Card, Badge, Btn, IconBtn, EmptyState, Chips,
|
|
4
|
+
Field, Input, Textarea, openModal, closeModal, confirmDialog,
|
|
5
|
+
toastOk, toastError, fmtDate, mount,
|
|
6
|
+
} from '/lib.js';
|
|
7
|
+
|
|
8
|
+
/* ----------------------------------------------------------- small helpers */
|
|
9
|
+
|
|
10
|
+
// textarea (newline-separated) → trimmed array, empties dropped.
|
|
11
|
+
function lines(str) {
|
|
12
|
+
return String(str || '').split('\n').map((s) => s.trim()).filter(Boolean);
|
|
13
|
+
}
|
|
14
|
+
// "a, b, c" → trimmed array, empties dropped.
|
|
15
|
+
function commas(str) {
|
|
16
|
+
return String(str || '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
17
|
+
}
|
|
18
|
+
function val(node) { return node ? node.value : ''; }
|
|
19
|
+
|
|
20
|
+
// Render a "YYYY-MM ~ YYYY-MM / 재직중" period label from a record.
|
|
21
|
+
function periodLabel(r) {
|
|
22
|
+
const start = r.start_date || '';
|
|
23
|
+
if (r.is_current) return start ? `${start} ~ 재직중` : '재직중';
|
|
24
|
+
const end = r.end_date || '';
|
|
25
|
+
if (start && end) return `${start} ~ ${end}`;
|
|
26
|
+
return start || end || '';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// A small grouped definition-list block for read-only display.
|
|
30
|
+
function kv(rows) {
|
|
31
|
+
const dl = el('dl', { class: 'kv' });
|
|
32
|
+
for (const [label, value] of rows) {
|
|
33
|
+
if (value == null || value === '') continue;
|
|
34
|
+
dl.append(el('dt', {}, label), el('dd', {}, value));
|
|
35
|
+
}
|
|
36
|
+
return dl;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/* ------------------------------------------------------------------ render */
|
|
40
|
+
|
|
41
|
+
export async function render(ctx) {
|
|
42
|
+
ctx.setTitle?.('프로필');
|
|
43
|
+
const [{ profile }, { experiences }, { projects }, { skills }, onboarding] = await Promise.all([
|
|
44
|
+
get('/api/profile'),
|
|
45
|
+
get('/api/experiences'),
|
|
46
|
+
get('/api/projects'),
|
|
47
|
+
get('/api/skills'),
|
|
48
|
+
get('/api/onboarding'),
|
|
49
|
+
]);
|
|
50
|
+
const p = profile || {};
|
|
51
|
+
|
|
52
|
+
// Re-run the whole render (refetch) after any mutation, then refresh nav.
|
|
53
|
+
const reload = async () => { await render(ctx); await ctx.refreshNav(); };
|
|
54
|
+
|
|
55
|
+
const wrap = el('div', { class: 'stack-4' });
|
|
56
|
+
|
|
57
|
+
wrap.append(CompletenessCard(onboarding));
|
|
58
|
+
wrap.append(BasicInfoCard(p, reload));
|
|
59
|
+
wrap.append(DesiredCard(p, reload));
|
|
60
|
+
wrap.append(WritingCard(p, reload));
|
|
61
|
+
wrap.append(ExperiencesCard(experiences || [], reload));
|
|
62
|
+
wrap.append(ProjectsCard(projects || [], reload));
|
|
63
|
+
wrap.append(SkillsCard(skills || [], reload));
|
|
64
|
+
|
|
65
|
+
mount(ctx.view, wrap);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/* ----------------------------------------------------- completeness header */
|
|
69
|
+
|
|
70
|
+
function CompletenessCard(o) {
|
|
71
|
+
const pct = o?.profile_completeness ?? 0;
|
|
72
|
+
const steps = (o?.next_steps || []).slice(0, 3);
|
|
73
|
+
return Card({
|
|
74
|
+
title: '프로필 완성도',
|
|
75
|
+
sub: `${pct}%`,
|
|
76
|
+
body: [
|
|
77
|
+
el('div', { class: 'progress', style: { marginBottom: steps.length ? '12px' : '0' } },
|
|
78
|
+
el('div', { class: 'progress__bar', style: { width: `${pct}%` } })),
|
|
79
|
+
steps.length
|
|
80
|
+
? el('div', { class: 'stack-2' }, ...steps.map((t) =>
|
|
81
|
+
el('div', { class: 'flex gap-2', style: { alignItems: 'flex-start' } },
|
|
82
|
+
icon('chevronRight', 'muted'),
|
|
83
|
+
el('span', { class: 'text-secondary text-sm' }, t))))
|
|
84
|
+
: null,
|
|
85
|
+
],
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/* --------------------------------------------------------- 1. 기본 정보 */
|
|
90
|
+
|
|
91
|
+
function BasicInfoCard(p, reload) {
|
|
92
|
+
const hasAny = p.name || p.email || p.phone || p.location || p.headline || p.summary || (p.links || []).length;
|
|
93
|
+
|
|
94
|
+
const body = hasAny ? [
|
|
95
|
+
kv([
|
|
96
|
+
['이름', p.name],
|
|
97
|
+
['이메일', p.email],
|
|
98
|
+
['연락처', p.phone],
|
|
99
|
+
['지역', p.location],
|
|
100
|
+
['한 줄 소개', p.headline],
|
|
101
|
+
]),
|
|
102
|
+
p.summary ? el('div', { class: 'mt-3' },
|
|
103
|
+
el('div', { class: 'muted text-sm mb-2' }, '자기소개 요약'),
|
|
104
|
+
el('div', { class: 'doc-preview' }, p.summary)) : null,
|
|
105
|
+
(p.links || []).length ? el('div', { class: 'mt-3 flex wrap gap-2' },
|
|
106
|
+
...p.links.map((l) => el('a', {
|
|
107
|
+
class: 'chip', href: l.url, attrs: { target: '_blank', rel: 'noopener' },
|
|
108
|
+
}, icon('link'), l.label || l.url))) : null,
|
|
109
|
+
] : EmptyState({
|
|
110
|
+
iconName: 'user',
|
|
111
|
+
title: '기본 정보가 비어 있어요',
|
|
112
|
+
body: '이름·연락처·한 줄 소개를 채워 두면 모든 문서의 기본 정보로 쓰입니다.',
|
|
113
|
+
action: Btn('정보 입력', { icon: 'edit', variant: 'primary', onClick: () => editBasic(p, reload) }),
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
return Card({
|
|
117
|
+
title: '기본 정보',
|
|
118
|
+
actions: hasAny ? Btn('수정', { icon: 'edit', sm: true, variant: 'ghost', onClick: () => editBasic(p, reload) }) : null,
|
|
119
|
+
body,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function editBasic(p, reload) {
|
|
124
|
+
const name = Input({ value: p.name || '', placeholder: '홍길동' });
|
|
125
|
+
const email = Input({ value: p.email || '', type: 'email', placeholder: 'you@example.com' });
|
|
126
|
+
const phone = Input({ value: p.phone || '', placeholder: '010-0000-0000' });
|
|
127
|
+
const location = Input({ value: p.location || '', placeholder: '서울' });
|
|
128
|
+
const headline = Input({ value: p.headline || '', placeholder: '예: 5년차 백엔드 엔지니어' });
|
|
129
|
+
const summary = Textarea({ value: p.summary || '', attrs: { rows: '5' }, placeholder: '자기소개 요약' });
|
|
130
|
+
// links as one "라벨 | URL" per line
|
|
131
|
+
const linksTa = Textarea({
|
|
132
|
+
value: (p.links || []).map((l) => `${l.label} | ${l.url}`).join('\n'),
|
|
133
|
+
attrs: { rows: '3' }, placeholder: 'GitHub | https://github.com/...\n포트폴리오 | https://...',
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
openModal({
|
|
137
|
+
title: '기본 정보 수정',
|
|
138
|
+
size: 'lg',
|
|
139
|
+
body: el('div', {},
|
|
140
|
+
Field('이름', name),
|
|
141
|
+
el('div', { class: 'grid grid--2' }, Field('이메일', email), Field('연락처', phone)),
|
|
142
|
+
Field('지역', location),
|
|
143
|
+
Field('한 줄 소개', headline, '직무 타이틀이나 강점을 한 문장으로'),
|
|
144
|
+
Field('자기소개 요약', summary),
|
|
145
|
+
Field('링크', linksTa, '한 줄에 하나씩 "라벨 | URL" 형식으로 입력하세요.'),
|
|
146
|
+
),
|
|
147
|
+
footer: (close) => [
|
|
148
|
+
Btn('취소', { onClick: close }),
|
|
149
|
+
Btn('기본 정보 저장', { variant: 'primary', onClick: async () => {
|
|
150
|
+
const links = lines(linksTa.value).map((line) => {
|
|
151
|
+
const i = line.indexOf('|');
|
|
152
|
+
const label = (i >= 0 ? line.slice(0, i) : line).trim();
|
|
153
|
+
const url = (i >= 0 ? line.slice(i + 1) : line).trim();
|
|
154
|
+
return { label: label || url, url };
|
|
155
|
+
}).filter((l) => l.url);
|
|
156
|
+
try {
|
|
157
|
+
await put('/api/profile', {
|
|
158
|
+
name: val(name).trim(), email: val(email).trim(), phone: val(phone).trim(),
|
|
159
|
+
location: val(location).trim(), headline: val(headline).trim(),
|
|
160
|
+
summary: val(summary).trim(), links,
|
|
161
|
+
});
|
|
162
|
+
toastOk('기본 정보를 저장했어요.');
|
|
163
|
+
close();
|
|
164
|
+
await reload();
|
|
165
|
+
} catch (e) { toastError(e); }
|
|
166
|
+
} }),
|
|
167
|
+
],
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/* --------------------------------------------------------- 2. 희망 조건 */
|
|
172
|
+
|
|
173
|
+
function DesiredCard(p, reload) {
|
|
174
|
+
const roles = p.desired_roles || [];
|
|
175
|
+
const hasAny = roles.length || p.desired_conditions;
|
|
176
|
+
|
|
177
|
+
const body = hasAny ? [
|
|
178
|
+
roles.length ? el('div', {},
|
|
179
|
+
el('div', { class: 'muted text-sm mb-2' }, '희망 직무'),
|
|
180
|
+
Chips(roles, { accent: true })) : null,
|
|
181
|
+
p.desired_conditions ? el('div', { class: roles.length ? 'mt-3' : '' },
|
|
182
|
+
el('div', { class: 'muted text-sm mb-2' }, '희망 조건'),
|
|
183
|
+
el('div', { class: 'doc-preview' }, p.desired_conditions)) : null,
|
|
184
|
+
] : EmptyState({
|
|
185
|
+
iconName: 'target',
|
|
186
|
+
title: '희망 조건이 비어 있어요',
|
|
187
|
+
body: '희망 직무와 근무 조건을 적어 두면 공고 매칭이 정확해집니다.',
|
|
188
|
+
action: Btn('희망 조건 입력', { icon: 'edit', variant: 'primary', onClick: () => editDesired(p, reload) }),
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
return Card({
|
|
192
|
+
title: '희망 조건',
|
|
193
|
+
actions: hasAny ? Btn('수정', { icon: 'edit', sm: true, variant: 'ghost', onClick: () => editDesired(p, reload) }) : null,
|
|
194
|
+
body,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function editDesired(p, reload) {
|
|
199
|
+
const roles = Input({ value: (p.desired_roles || []).join(', '), placeholder: '백엔드 엔지니어, 플랫폼 엔지니어' });
|
|
200
|
+
const conditions = Textarea({ value: p.desired_conditions || '', attrs: { rows: '4' }, placeholder: '예: 연봉 6,000 이상 / 서울·재택 / 정규직' });
|
|
201
|
+
openModal({
|
|
202
|
+
title: '희망 조건 수정',
|
|
203
|
+
body: el('div', {},
|
|
204
|
+
Field('희망 직무', roles, '쉼표(,)로 구분해 여러 개 입력할 수 있어요.'),
|
|
205
|
+
Field('희망 조건', conditions, '연봉·지역·근무형태 등 자유롭게'),
|
|
206
|
+
),
|
|
207
|
+
footer: (close) => [
|
|
208
|
+
Btn('취소', { onClick: close }),
|
|
209
|
+
Btn('희망 조건 저장', { variant: 'primary', onClick: async () => {
|
|
210
|
+
try {
|
|
211
|
+
await put('/api/profile', {
|
|
212
|
+
desired_roles: commas(roles.value),
|
|
213
|
+
desired_conditions: val(conditions).trim(),
|
|
214
|
+
});
|
|
215
|
+
toastOk('희망 조건을 저장했어요.');
|
|
216
|
+
close();
|
|
217
|
+
await reload();
|
|
218
|
+
} catch (e) { toastError(e); }
|
|
219
|
+
} }),
|
|
220
|
+
],
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/* ----------------------------------------------- 3. 자기소개서 설정 */
|
|
225
|
+
|
|
226
|
+
function WritingCard(p, reload) {
|
|
227
|
+
const emphasis = p.emphasis_points || [];
|
|
228
|
+
const hasAny = p.preferred_tone || emphasis.length;
|
|
229
|
+
|
|
230
|
+
const body = [
|
|
231
|
+
!hasAny ? el('div', { class: 'callout', style: { marginBottom: '12px' } },
|
|
232
|
+
icon('sparkle'),
|
|
233
|
+
el('div', {},
|
|
234
|
+
el('div', { class: 'callout__title' }, 'AI 글쓰기 품질에 직접 반영돼요'),
|
|
235
|
+
el('div', { class: 'callout__body' }, '선호 문체와 강조 포인트를 적어 두면 자기소개서·면접 답변의 톤이 일관되게 맞춰집니다.'))) : null,
|
|
236
|
+
hasAny ? kv([
|
|
237
|
+
['선호 문체', p.preferred_tone],
|
|
238
|
+
]) : null,
|
|
239
|
+
emphasis.length ? el('div', { class: p.preferred_tone ? 'mt-3' : '' },
|
|
240
|
+
el('div', { class: 'muted text-sm mb-2' }, '강조 포인트'),
|
|
241
|
+
Chips(emphasis, { accent: true })) : null,
|
|
242
|
+
!hasAny ? el('p', { class: 'muted', style: { margin: '4px 0 0' } }, '아직 선호 문체·강조 포인트가 없어요.') : null,
|
|
243
|
+
];
|
|
244
|
+
|
|
245
|
+
return Card({
|
|
246
|
+
title: '자기소개서 설정',
|
|
247
|
+
actions: Btn(hasAny ? '수정' : '설정', { icon: 'edit', sm: true, variant: 'ghost', onClick: () => editWriting(p, reload) }),
|
|
248
|
+
body,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function editWriting(p, reload) {
|
|
253
|
+
const tone = Input({ value: p.preferred_tone || '', placeholder: '예: 담백하고 구체적' });
|
|
254
|
+
const emphasis = Textarea({ value: (p.emphasis_points || []).join('\n'), attrs: { rows: '5' }, placeholder: '문제 해결 능력\n주도적인 협업\n정량 성과' });
|
|
255
|
+
openModal({
|
|
256
|
+
title: '자기소개서 설정',
|
|
257
|
+
body: el('div', {},
|
|
258
|
+
Field('선호 문체', tone, '예: 담백하고 구체적 / 자신감 있게 / 진솔하게'),
|
|
259
|
+
Field('강조 포인트', emphasis, '한 줄에 하나씩. AI가 글을 쓸 때 이 포인트를 우선 반영해요.'),
|
|
260
|
+
),
|
|
261
|
+
footer: (close) => [
|
|
262
|
+
Btn('취소', { onClick: close }),
|
|
263
|
+
Btn('설정 저장', { variant: 'primary', onClick: async () => {
|
|
264
|
+
try {
|
|
265
|
+
await put('/api/profile', {
|
|
266
|
+
preferred_tone: val(tone).trim(),
|
|
267
|
+
emphasis_points: lines(emphasis.value),
|
|
268
|
+
});
|
|
269
|
+
toastOk('자기소개서 설정을 저장했어요.');
|
|
270
|
+
close();
|
|
271
|
+
await reload();
|
|
272
|
+
} catch (e) { toastError(e); }
|
|
273
|
+
} }),
|
|
274
|
+
],
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/* ---------------------------------------------------- 4. 경력 (Experiences) */
|
|
279
|
+
|
|
280
|
+
function ExperiencesCard(items, reload) {
|
|
281
|
+
const add = Btn('추가', { icon: 'plus', sm: true, variant: 'ghost', onClick: () => editExperience(null, reload) });
|
|
282
|
+
|
|
283
|
+
if (!items.length) {
|
|
284
|
+
return Card({
|
|
285
|
+
title: '경력',
|
|
286
|
+
actions: add,
|
|
287
|
+
body: EmptyState({
|
|
288
|
+
iconName: 'briefcase',
|
|
289
|
+
title: '등록된 경력이 없어요',
|
|
290
|
+
body: '주요 경력을 추가하면 적합도 분석과 자기소개서 품질이 올라갑니다.',
|
|
291
|
+
action: Btn('경력 추가', { icon: 'plus', variant: 'primary', onClick: () => editExperience(null, reload) }),
|
|
292
|
+
}),
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const rows = items.map((x) => el('div', { class: 'tl-item' + (x.is_current ? ' is-current' : '') },
|
|
297
|
+
el('div', { class: 'tl-item__rail' }, el('div', { class: 'tl-item__dot' }), el('div', { class: 'tl-item__line' })),
|
|
298
|
+
el('div', { class: 'tl-item__body' },
|
|
299
|
+
el('div', { class: 'flex between wrap gap-2' },
|
|
300
|
+
el('div', {},
|
|
301
|
+
el('div', { class: 'flex gap-2 wrap', style: { alignItems: 'baseline' } },
|
|
302
|
+
el('span', { class: 'strong' }, x.company),
|
|
303
|
+
x.role ? el('span', { class: 'text-secondary' }, x.role) : null,
|
|
304
|
+
x.employment_type ? el('span', { class: 'muted text-sm' }, x.employment_type) : null),
|
|
305
|
+
periodLabel(x) ? el('div', { class: 'muted text-sm' }, periodLabel(x)) : null),
|
|
306
|
+
el('div', { class: 'flex gap-2' },
|
|
307
|
+
IconBtn('edit', { title: '수정', onClick: () => editExperience(x, reload) }),
|
|
308
|
+
IconBtn('trash', { title: '삭제', variant: 'danger', onClick: () => removeExperience(x, reload) }))),
|
|
309
|
+
x.description ? el('div', { class: 'doc-preview mt-2' }, x.description) : null,
|
|
310
|
+
(x.achievements || []).length ? el('ul', { class: 'mt-2', style: { margin: '8px 0 0', paddingLeft: '18px' } },
|
|
311
|
+
...x.achievements.map((a) => el('li', { class: 'text-secondary text-sm', style: { marginBottom: '3px' } }, a))) : null,
|
|
312
|
+
(x.tech || []).length ? el('div', { class: 'mt-2' }, Chips(x.tech)) : null,
|
|
313
|
+
)));
|
|
314
|
+
|
|
315
|
+
return Card({
|
|
316
|
+
title: '경력',
|
|
317
|
+
sub: `${items.length}건`,
|
|
318
|
+
actions: add,
|
|
319
|
+
body: el('div', { class: 'timeline' }, ...rows),
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async function removeExperience(x, reload) {
|
|
324
|
+
const ok = await confirmDialog({ title: '경력 삭제', message: `"${x.company}" 경력을 삭제할까요?`, confirmLabel: '삭제', danger: true });
|
|
325
|
+
if (!ok) return;
|
|
326
|
+
try { await del(`/api/experiences/${x.id}`); toastOk('삭제했어요.'); await reload(); }
|
|
327
|
+
catch (e) { toastError(e); }
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function editExperience(x, reload) {
|
|
331
|
+
const r = x || {};
|
|
332
|
+
const company = Input({ value: r.company || '', placeholder: '회사명 (필수)' });
|
|
333
|
+
const role = Input({ value: r.role || '', placeholder: '직무 / 직책' });
|
|
334
|
+
const empType = Input({ value: r.employment_type || '', placeholder: '정규직 / 계약직 / 인턴 / 프리랜서' });
|
|
335
|
+
const start = Input({ value: r.start_date || '', placeholder: 'YYYY-MM' });
|
|
336
|
+
const end = Input({ value: r.end_date || '', placeholder: 'YYYY-MM' });
|
|
337
|
+
const isCurrent = el('input', { type: 'checkbox', checked: !!r.is_current });
|
|
338
|
+
const sync = () => { end.disabled = isCurrent.checked; if (isCurrent.checked) end.value = ''; };
|
|
339
|
+
isCurrent.addEventListener('change', sync);
|
|
340
|
+
const desc = Textarea({ value: r.description || '', attrs: { rows: '3' }, placeholder: '담당 업무 / 역할' });
|
|
341
|
+
const achievements = Textarea({ value: (r.achievements || []).join('\n'), attrs: { rows: '4' }, placeholder: '결제 전환율 12% 개선\n월 배포 횟수 3배 증가' });
|
|
342
|
+
const tech = Input({ value: (r.tech || []).join(', '), placeholder: 'TypeScript, Node.js, PostgreSQL' });
|
|
343
|
+
sync();
|
|
344
|
+
|
|
345
|
+
openModal({
|
|
346
|
+
title: x ? '경력 수정' : '경력 추가',
|
|
347
|
+
size: 'lg',
|
|
348
|
+
body: el('div', {},
|
|
349
|
+
Field('회사', company),
|
|
350
|
+
el('div', { class: 'grid grid--2' }, Field('직무', role), Field('고용형태', empType)),
|
|
351
|
+
el('div', { class: 'grid grid--2' }, Field('시작 (YYYY-MM)', start), Field('종료 (YYYY-MM)', end)),
|
|
352
|
+
Field(null, el('label', { class: 'flex gap-2', style: { alignItems: 'center', fontWeight: '500' } }, isCurrent, el('span', {}, '현재 재직 중'))),
|
|
353
|
+
Field('업무 설명', desc),
|
|
354
|
+
Field('주요 성과', achievements, '한 줄에 하나씩. 정량 지표를 함께 적으면 좋아요.'),
|
|
355
|
+
Field('사용 기술', tech, '쉼표(,)로 구분'),
|
|
356
|
+
),
|
|
357
|
+
footer: (close) => [
|
|
358
|
+
Btn('취소', { onClick: close }),
|
|
359
|
+
Btn('경력 저장', { variant: 'primary', onClick: async () => {
|
|
360
|
+
if (!val(company).trim()) { toastError(new Error('회사명을 입력해 주세요.')); return; }
|
|
361
|
+
const payload = {
|
|
362
|
+
company: val(company).trim(),
|
|
363
|
+
role: val(role).trim(),
|
|
364
|
+
employment_type: val(empType).trim(),
|
|
365
|
+
start_date: val(start).trim(),
|
|
366
|
+
end_date: isCurrent.checked ? '' : val(end).trim(),
|
|
367
|
+
is_current: isCurrent.checked,
|
|
368
|
+
description: val(desc).trim(),
|
|
369
|
+
achievements: lines(achievements.value),
|
|
370
|
+
tech: commas(tech.value),
|
|
371
|
+
};
|
|
372
|
+
try {
|
|
373
|
+
if (x) await put(`/api/experiences/${x.id}`, payload);
|
|
374
|
+
else await post('/api/experiences', payload);
|
|
375
|
+
toastOk(x ? '경력을 수정했어요.' : '경력을 추가했어요.');
|
|
376
|
+
close();
|
|
377
|
+
await reload();
|
|
378
|
+
} catch (e) { toastError(e); }
|
|
379
|
+
} }),
|
|
380
|
+
],
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/* ----------------------------------------------------- 5. 프로젝트 (Projects) */
|
|
385
|
+
|
|
386
|
+
function ProjectsCard(items, reload) {
|
|
387
|
+
const add = Btn('추가', { icon: 'plus', sm: true, variant: 'ghost', onClick: () => editProject(null, reload) });
|
|
388
|
+
|
|
389
|
+
if (!items.length) {
|
|
390
|
+
return Card({
|
|
391
|
+
title: '프로젝트',
|
|
392
|
+
actions: add,
|
|
393
|
+
body: EmptyState({
|
|
394
|
+
iconName: 'layers',
|
|
395
|
+
title: '등록된 프로젝트가 없어요',
|
|
396
|
+
body: '대표 프로젝트를 정리해 두면 자기소개서·면접에서 구체적인 근거로 활용돼요.',
|
|
397
|
+
action: Btn('프로젝트 추가', { icon: 'plus', variant: 'primary', onClick: () => editProject(null, reload) }),
|
|
398
|
+
}),
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const cards = items.map((x) => el('div', { class: 'subcard' },
|
|
403
|
+
el('div', { class: 'flex between wrap gap-2' },
|
|
404
|
+
el('div', {},
|
|
405
|
+
el('div', { class: 'flex gap-2 wrap', style: { alignItems: 'baseline' } },
|
|
406
|
+
el('span', { class: 'strong' }, x.name),
|
|
407
|
+
x.role ? el('span', { class: 'text-secondary text-sm' }, x.role) : null),
|
|
408
|
+
periodProject(x) ? el('div', { class: 'muted text-sm' }, periodProject(x)) : null),
|
|
409
|
+
el('div', { class: 'flex gap-2' },
|
|
410
|
+
x.url ? el('a', { class: 'btn btn--ghost icon-btn', href: x.url, title: '링크 열기', attrs: { target: '_blank', rel: 'noopener', 'aria-label': '링크 열기' } }, icon('external')) : null,
|
|
411
|
+
IconBtn('edit', { title: '수정', onClick: () => editProject(x, reload) }),
|
|
412
|
+
IconBtn('trash', { title: '삭제', variant: 'danger', onClick: () => removeProject(x, reload) }))),
|
|
413
|
+
x.description ? el('div', { class: 'doc-preview mt-2' }, x.description) : null,
|
|
414
|
+
(x.highlights || []).length ? el('ul', { style: { margin: '8px 0 0', paddingLeft: '18px' } },
|
|
415
|
+
...x.highlights.map((h) => el('li', { class: 'text-secondary text-sm', style: { marginBottom: '3px' } }, h))) : null,
|
|
416
|
+
(x.tech || []).length ? el('div', { class: 'mt-2' }, Chips(x.tech)) : null,
|
|
417
|
+
));
|
|
418
|
+
|
|
419
|
+
return Card({
|
|
420
|
+
title: '프로젝트',
|
|
421
|
+
sub: `${items.length}건`,
|
|
422
|
+
actions: add,
|
|
423
|
+
body: el('div', { class: 'stack-3' }, ...cards),
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function periodProject(x) {
|
|
428
|
+
if (x.start_date && x.end_date) return `${x.start_date} ~ ${x.end_date}`;
|
|
429
|
+
return x.start_date || x.end_date || '';
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
async function removeProject(x, reload) {
|
|
433
|
+
const ok = await confirmDialog({ title: '프로젝트 삭제', message: `"${x.name}" 프로젝트를 삭제할까요?`, confirmLabel: '삭제', danger: true });
|
|
434
|
+
if (!ok) return;
|
|
435
|
+
try { await del(`/api/projects/${x.id}`); toastOk('삭제했어요.'); await reload(); }
|
|
436
|
+
catch (e) { toastError(e); }
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function editProject(x, reload) {
|
|
440
|
+
const r = x || {};
|
|
441
|
+
const name = Input({ value: r.name || '', placeholder: '프로젝트명 (필수)' });
|
|
442
|
+
const role = Input({ value: r.role || '', placeholder: '맡은 역할' });
|
|
443
|
+
const start = Input({ value: r.start_date || '', placeholder: 'YYYY-MM' });
|
|
444
|
+
const end = Input({ value: r.end_date || '', placeholder: 'YYYY-MM' });
|
|
445
|
+
const url = Input({ value: r.url || '', placeholder: 'https://...' });
|
|
446
|
+
const desc = Textarea({ value: r.description || '', attrs: { rows: '3' }, placeholder: '프로젝트 개요 / 목적' });
|
|
447
|
+
const highlights = Textarea({ value: (r.highlights || []).join('\n'), attrs: { rows: '4' }, placeholder: '핵심 성과를 한 줄에 하나씩' });
|
|
448
|
+
const tech = Input({ value: (r.tech || []).join(', '), placeholder: 'React, AWS, GraphQL' });
|
|
449
|
+
|
|
450
|
+
openModal({
|
|
451
|
+
title: x ? '프로젝트 수정' : '프로젝트 추가',
|
|
452
|
+
size: 'lg',
|
|
453
|
+
body: el('div', {},
|
|
454
|
+
Field('프로젝트명', name),
|
|
455
|
+
el('div', { class: 'grid grid--2' }, Field('역할', role), Field('링크', url)),
|
|
456
|
+
el('div', { class: 'grid grid--2' }, Field('시작 (YYYY-MM)', start), Field('종료 (YYYY-MM)', end)),
|
|
457
|
+
Field('설명', desc),
|
|
458
|
+
Field('주요 내용', highlights, '한 줄에 하나씩'),
|
|
459
|
+
Field('사용 기술', tech, '쉼표(,)로 구분'),
|
|
460
|
+
),
|
|
461
|
+
footer: (close) => [
|
|
462
|
+
Btn('취소', { onClick: close }),
|
|
463
|
+
Btn('프로젝트 저장', { variant: 'primary', onClick: async () => {
|
|
464
|
+
if (!val(name).trim()) { toastError(new Error('프로젝트명을 입력해 주세요.')); return; }
|
|
465
|
+
const payload = {
|
|
466
|
+
name: val(name).trim(),
|
|
467
|
+
role: val(role).trim(),
|
|
468
|
+
description: val(desc).trim(),
|
|
469
|
+
highlights: lines(highlights.value),
|
|
470
|
+
tech: commas(tech.value),
|
|
471
|
+
url: val(url).trim(),
|
|
472
|
+
start_date: val(start).trim(),
|
|
473
|
+
end_date: val(end).trim(),
|
|
474
|
+
};
|
|
475
|
+
try {
|
|
476
|
+
if (x) await put(`/api/projects/${x.id}`, payload);
|
|
477
|
+
else await post('/api/projects', payload);
|
|
478
|
+
toastOk(x ? '프로젝트를 수정했어요.' : '프로젝트를 추가했어요.');
|
|
479
|
+
close();
|
|
480
|
+
await reload();
|
|
481
|
+
} catch (e) { toastError(e); }
|
|
482
|
+
} }),
|
|
483
|
+
],
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/* -------------------------------------------------------- 6. 기술스택 (Skills) */
|
|
488
|
+
|
|
489
|
+
function SkillsCard(items, reload) {
|
|
490
|
+
const add = Btn('추가', { icon: 'plus', sm: true, variant: 'ghost', onClick: () => editSkill(null, reload) });
|
|
491
|
+
|
|
492
|
+
if (!items.length) {
|
|
493
|
+
return Card({
|
|
494
|
+
title: '기술스택',
|
|
495
|
+
actions: add,
|
|
496
|
+
body: EmptyState({
|
|
497
|
+
iconName: 'sparkle',
|
|
498
|
+
title: '등록된 기술이 없어요',
|
|
499
|
+
body: '보유 기술스택을 정리해 두면 공고 키워드 매칭이 정확해집니다.',
|
|
500
|
+
action: Btn('기술 추가', { icon: 'plus', variant: 'primary', onClick: () => editSkill(null, reload) }),
|
|
501
|
+
}),
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// Group by category (uncategorized last).
|
|
506
|
+
const groups = new Map();
|
|
507
|
+
for (const s of items) {
|
|
508
|
+
const key = s.category || '기타';
|
|
509
|
+
if (!groups.has(key)) groups.set(key, []);
|
|
510
|
+
groups.get(key).push(s);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const blocks = [...groups.entries()].map(([cat, list]) =>
|
|
514
|
+
el('div', {},
|
|
515
|
+
el('div', { class: 'muted text-sm mb-2' }, cat),
|
|
516
|
+
el('div', { class: 'stack-2' }, ...list.map((s) =>
|
|
517
|
+
el('div', { class: 'flex between gap-2', style: { padding: '4px 0' } },
|
|
518
|
+
el('div', { class: 'flex gap-2 wrap', style: { alignItems: 'baseline' } },
|
|
519
|
+
el('span', { class: 'strong' }, s.name),
|
|
520
|
+
s.level ? el('span', { class: 'badge badge--applied' }, el('span', { class: 'dot' }), s.level) : null,
|
|
521
|
+
s.years != null ? el('span', { class: 'muted text-sm' }, `${s.years}년`) : null),
|
|
522
|
+
el('div', { class: 'flex gap-2' },
|
|
523
|
+
IconBtn('edit', { title: '수정', onClick: () => editSkill(s, reload) }),
|
|
524
|
+
IconBtn('trash', { title: '삭제', variant: 'danger', onClick: () => removeSkill(s, reload) })))))));
|
|
525
|
+
|
|
526
|
+
return Card({
|
|
527
|
+
title: '기술스택',
|
|
528
|
+
sub: `${items.length}개`,
|
|
529
|
+
actions: add,
|
|
530
|
+
body: el('div', { class: 'stack-3' }, ...blocks),
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
async function removeSkill(s, reload) {
|
|
535
|
+
const ok = await confirmDialog({ title: '기술 삭제', message: `"${s.name}"을(를) 삭제할까요?`, confirmLabel: '삭제', danger: true });
|
|
536
|
+
if (!ok) return;
|
|
537
|
+
try { await del(`/api/skills/${s.id}`); toastOk('삭제했어요.'); await reload(); }
|
|
538
|
+
catch (e) { toastError(e); }
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function editSkill(s, reload) {
|
|
542
|
+
const r = s || {};
|
|
543
|
+
const name = Input({ value: r.name || '', placeholder: '기술명 (필수)' });
|
|
544
|
+
const category = Input({ value: r.category || '', placeholder: '언어 / 프레임워크 / 툴 / 소프트스킬' });
|
|
545
|
+
const level = Input({ value: r.level || '', placeholder: '상 / 중 / 하 또는 자유 서술' });
|
|
546
|
+
const years = Input({ value: r.years != null ? String(r.years) : '', type: 'number', attrs: { min: '0', step: '0.5' }, placeholder: '연차' });
|
|
547
|
+
|
|
548
|
+
openModal({
|
|
549
|
+
title: s ? '기술 수정' : '기술 추가',
|
|
550
|
+
body: el('div', {},
|
|
551
|
+
Field('기술명', name),
|
|
552
|
+
Field('분류', category),
|
|
553
|
+
el('div', { class: 'grid grid--2' }, Field('숙련도', level), Field('연차', years)),
|
|
554
|
+
),
|
|
555
|
+
footer: (close) => [
|
|
556
|
+
Btn('취소', { onClick: close }),
|
|
557
|
+
Btn('기술 저장', { variant: 'primary', onClick: async () => {
|
|
558
|
+
if (!val(name).trim()) { toastError(new Error('기술명을 입력해 주세요.')); return; }
|
|
559
|
+
const yrs = val(years).trim();
|
|
560
|
+
const payload = {
|
|
561
|
+
name: val(name).trim(),
|
|
562
|
+
category: val(category).trim(),
|
|
563
|
+
level: val(level).trim(),
|
|
564
|
+
};
|
|
565
|
+
if (yrs !== '' && !Number.isNaN(Number(yrs))) payload.years = Number(yrs);
|
|
566
|
+
try {
|
|
567
|
+
if (s) await put(`/api/skills/${s.id}`, payload);
|
|
568
|
+
else await post('/api/skills', payload);
|
|
569
|
+
toastOk(s ? '기술을 수정했어요.' : '기술을 추가했어요.');
|
|
570
|
+
close();
|
|
571
|
+
await reload();
|
|
572
|
+
} catch (e) { toastError(e); }
|
|
573
|
+
} }),
|
|
574
|
+
],
|
|
575
|
+
});
|
|
576
|
+
}
|