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,263 @@
|
|
|
1
|
+
// Home — a calm "career command center": where do I stand, and what should I do next?
|
|
2
|
+
//
|
|
3
|
+
// Copy rule (avoid the old repetition): setup guidance lives in EXACTLY one place —
|
|
4
|
+
// the 시작하기 card. Every empty state is ONE screen-specific sentence; never repeat
|
|
5
|
+
// the same "AI에게 보내면 저장됩니다" instruction across surfaces.
|
|
6
|
+
import {
|
|
7
|
+
el, get, icon, navigate, Card, Badge, Btn, EmptyState, ListRow, CheckRow,
|
|
8
|
+
Stat, PageHead, fmtDate, fmtRelative, scoreClass, mount,
|
|
9
|
+
} from '/lib.js';
|
|
10
|
+
|
|
11
|
+
// status code → saturated theme colour. MUST track the badge--{status} hues so a
|
|
12
|
+
// colour means the same stage in the pipeline bar, the badges, and the board.
|
|
13
|
+
const STATUS_COLOR = {
|
|
14
|
+
draft: 'var(--slate)', planned: 'var(--blue)', applied: 'var(--violet)',
|
|
15
|
+
document_passed: 'var(--teal)', interview: 'var(--amber)',
|
|
16
|
+
final_passed: 'var(--green)', on_hold: 'var(--slate)', rejected: 'var(--red)',
|
|
17
|
+
};
|
|
18
|
+
const PIPE_ORDER = ['draft', 'planned', 'applied', 'document_passed', 'interview', 'final_passed', 'on_hold', 'rejected'];
|
|
19
|
+
|
|
20
|
+
// activity type → icon + theme hue (soft tinted chip + coloured stroke)
|
|
21
|
+
const ACTIVITY_META = {
|
|
22
|
+
profile_updated: { icon: 'user', hue: 'slate' },
|
|
23
|
+
resume_added: { icon: 'file', hue: 'slate' },
|
|
24
|
+
cover_letter_added: { icon: 'file', hue: 'teal' },
|
|
25
|
+
cover_letter_version_saved: { icon: 'file', hue: 'teal' },
|
|
26
|
+
job_saved: { icon: 'briefcase', hue: 'blue' },
|
|
27
|
+
fit_analysis_saved: { icon: 'target', hue: 'violet' },
|
|
28
|
+
application_status_changed: { icon: 'layers', hue: 'blue' },
|
|
29
|
+
interview_prep_saved: { icon: 'mic', hue: 'amber' },
|
|
30
|
+
document_exported: { icon: 'download', hue: 'slate' },
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export async function render(ctx) {
|
|
34
|
+
const [s, profileRes] = await Promise.all([get('/api/summary'), get('/api/profile')]);
|
|
35
|
+
const name = profileRes.profile?.name;
|
|
36
|
+
|
|
37
|
+
// exactly one primary action per screen, pinned to the topbar
|
|
38
|
+
ctx.setActions([Btn('공고 추가', { icon: 'plus', variant: 'primary', onClick: () => navigate('/jobs') })]);
|
|
39
|
+
|
|
40
|
+
// day one: nothing saved yet → just greeting + the single getting-started card
|
|
41
|
+
// (skip the pipeline + a wall of empty cards).
|
|
42
|
+
const firstRun = !s.onboarding.completed
|
|
43
|
+
&& s.counts.jobs === 0 && s.counts.active_applications === 0 && s.counts.cover_letters === 0;
|
|
44
|
+
|
|
45
|
+
const wrap = el('div', { class: 'stack-4' });
|
|
46
|
+
wrap.append(Greeting(name, s, firstRun));
|
|
47
|
+
|
|
48
|
+
if (firstRun) {
|
|
49
|
+
wrap.append(el('div', { class: 'firstrun-hero' }, GettingStarted(s.onboarding)));
|
|
50
|
+
return mount(ctx.view, wrap);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (!s.onboarding.completed) wrap.append(GettingStarted(s.onboarding));
|
|
54
|
+
wrap.append(StatRow(s.counts));
|
|
55
|
+
wrap.append(Pipeline(s.status_breakdown));
|
|
56
|
+
wrap.append(el('div', { class: 'grid grid--2' },
|
|
57
|
+
el('div', { class: 'stack-4' }, ActionLane(s.interview_todo, s.in_progress), RecentJobs(s.recent_jobs)),
|
|
58
|
+
el('div', { class: 'stack-4' }, Activity(s.recent_activity)),
|
|
59
|
+
));
|
|
60
|
+
|
|
61
|
+
mount(ctx.view, wrap);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/* ----------------------------------------------------------------- greeting band */
|
|
65
|
+
function Greeting(name, s, firstRun) {
|
|
66
|
+
let focus;
|
|
67
|
+
if (firstRun) focus = '시작해 볼까요 — 먼저 기본 정보를 채워 주세요.';
|
|
68
|
+
else if (s.interview_todo.length) focus = `면접 준비가 필요한 지원 ${s.interview_todo.length}건이 있어요.`;
|
|
69
|
+
else if (s.in_progress.length) focus = `진행 중인 지원 ${s.in_progress.length}건을 확인하세요.`;
|
|
70
|
+
else if (s.counts.jobs) focus = `저장된 공고 ${s.counts.jobs}건 · 새 적합도 분석을 받아보세요.`;
|
|
71
|
+
else focus = '오늘도 한 걸음 나아가 볼까요.';
|
|
72
|
+
return PageHead({ title: name ? `${name}님, 안녕하세요` : '환영합니다', desc: focus });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/* ----------------------------------------------- getting started (the one guidance surface) */
|
|
76
|
+
function GettingStarted(o) {
|
|
77
|
+
const steps = [
|
|
78
|
+
{ done: o.has_profile, label: '기본 프로필 작성', to: '/profile' },
|
|
79
|
+
{ done: o.has_resume || o.has_experience, label: '이력서 또는 경력 추가', to: '/profile' },
|
|
80
|
+
{ done: o.has_cover_letter, label: '기존 자기소개서 등록', to: '/documents' },
|
|
81
|
+
{ done: o.has_job, label: '관심 공고 저장', to: '/jobs' },
|
|
82
|
+
];
|
|
83
|
+
return Card({
|
|
84
|
+
title: '시작하기',
|
|
85
|
+
sub: `프로필 완성도 ${o.profile_completeness}%`,
|
|
86
|
+
body: [
|
|
87
|
+
el('div', { class: 'progress' }, el('div', { class: 'progress__bar', style: { width: `${o.profile_completeness}%` } })),
|
|
88
|
+
el('p', { class: 'text-secondary text-sm', style: { margin: '12px 0 8px' } },
|
|
89
|
+
'완성할수록 AI의 적합도 분석과 자기소개서 품질이 좋아져요.'),
|
|
90
|
+
el('div', {}, ...steps.map((it) => CheckRow({ done: it.done, label: it.label, onClick: () => navigate(it.to) }))),
|
|
91
|
+
],
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/* ----------------------------------------------- KPI summary tiles (at-a-glance) */
|
|
96
|
+
function StatTile(label, value, iconName, to) {
|
|
97
|
+
const tile = Stat({ label, value, iconName });
|
|
98
|
+
tile.classList.add('is-clickable');
|
|
99
|
+
tile.addEventListener('click', () => navigate(to));
|
|
100
|
+
return tile;
|
|
101
|
+
}
|
|
102
|
+
function StatRow(counts) {
|
|
103
|
+
return el('div', { class: 'grid grid--4' },
|
|
104
|
+
StatTile('저장 공고', counts.jobs, 'briefcase', '/jobs'),
|
|
105
|
+
StatTile('진행 중 지원', counts.active_applications, 'layers', '/applications'),
|
|
106
|
+
StatTile('자기소개서', counts.cover_letters, 'file', '/documents'),
|
|
107
|
+
StatTile('면접 준비', counts.interview_pending, 'mic', '/interview'),
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/* ----------------------------------------------- pipeline hero (status_breakdown) */
|
|
112
|
+
function Pipeline(breakdown) {
|
|
113
|
+
const byStatus = Object.fromEntries(breakdown.map((b) => [b.status, b.count]));
|
|
114
|
+
const c = (st) => byStatus[st] || 0;
|
|
115
|
+
const total = breakdown.reduce((n, b) => n + b.count, 0);
|
|
116
|
+
const open = () => navigate('/applications');
|
|
117
|
+
|
|
118
|
+
let body;
|
|
119
|
+
if (total === 0) {
|
|
120
|
+
body = el('p', { class: 'muted', style: { margin: 0 } },
|
|
121
|
+
'아직 지원 단계가 없어요. 공고에 지원 상태를 표시하면 여기에 파이프라인이 채워져요.');
|
|
122
|
+
} else {
|
|
123
|
+
const segs = PIPE_ORDER.filter((st) => c(st) > 0).map((st) =>
|
|
124
|
+
el('div', {
|
|
125
|
+
class: 'pipebar__seg',
|
|
126
|
+
style: { flexGrow: String(c(st)), background: STATUS_COLOR[st] },
|
|
127
|
+
title: `${breakdown.find((b) => b.status === st).label} ${c(st)}`,
|
|
128
|
+
onClick: open,
|
|
129
|
+
}));
|
|
130
|
+
|
|
131
|
+
const reached = c('applied') + c('document_passed') + c('interview') + c('final_passed');
|
|
132
|
+
const docPass = c('document_passed') + c('interview') + c('final_passed');
|
|
133
|
+
const intv = c('interview') + c('final_passed');
|
|
134
|
+
const fin = c('final_passed');
|
|
135
|
+
const pct = (num, den) => (den > 0 ? Math.round((num / den) * 100) : null);
|
|
136
|
+
const conv = (num, den, label) => (pct(num, den) != null ? `${label} ${pct(num, den)}%` : null);
|
|
137
|
+
|
|
138
|
+
const tiles = [
|
|
139
|
+
{ st: 'applied', label: '지원', value: reached, conv: total ? `전체 ${total}건` : null },
|
|
140
|
+
{ st: 'document_passed', label: '서류 합격', value: docPass, conv: conv(docPass, reached, '지원 대비') },
|
|
141
|
+
{ st: 'interview', label: '면접', value: intv, conv: conv(intv, reached, '지원 대비') },
|
|
142
|
+
{ st: 'final_passed', label: '최종 합격', value: fin, conv: conv(fin, intv, '면접 대비') || conv(fin, reached, '지원 대비') },
|
|
143
|
+
];
|
|
144
|
+
|
|
145
|
+
body = [
|
|
146
|
+
el('div', { class: 'pipebar' }, ...segs),
|
|
147
|
+
el('div', { class: 'pipefunnel' }, ...tiles.map((t) =>
|
|
148
|
+
el('div', { class: 'pipefunnel__tile', onClick: open },
|
|
149
|
+
el('div', { class: 'pipefunnel__label' },
|
|
150
|
+
el('span', { class: 'pipefunnel__dot', style: { background: STATUS_COLOR[t.st] } }), t.label),
|
|
151
|
+
el('div', { class: 'pipefunnel__value tnum' }, String(t.value)),
|
|
152
|
+
t.conv && el('div', { class: 'pipefunnel__conv' }, t.conv)))),
|
|
153
|
+
];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return Card({
|
|
157
|
+
title: '지원 파이프라인',
|
|
158
|
+
actions: Btn('보드 열기', { sm: true, variant: 'ghost', onClick: open }),
|
|
159
|
+
body,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/* ----------------------------------------------- action lane (what to do next) */
|
|
164
|
+
function statusDot(status) {
|
|
165
|
+
return el('span', { class: 'list-row__dot', style: { background: STATUS_COLOR[status] || 'var(--text-tertiary)' } });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function ActionLane(interviewTodo, inProgress) {
|
|
169
|
+
const rows = [];
|
|
170
|
+
for (const it of interviewTodo) {
|
|
171
|
+
rows.push(ListRow({
|
|
172
|
+
leading: statusDot('document_passed'),
|
|
173
|
+
title: it.job.company,
|
|
174
|
+
sub: it.job.position,
|
|
175
|
+
trailing: el('span', { class: 'badge badge--accent' }, el('span', { class: 'dot' }), '면접 준비'),
|
|
176
|
+
onClick: () => navigate(`/jobs/${it.job.id}`),
|
|
177
|
+
}));
|
|
178
|
+
}
|
|
179
|
+
for (const it of inProgress) {
|
|
180
|
+
if (rows.length >= 6) break;
|
|
181
|
+
rows.push(ListRow({
|
|
182
|
+
leading: statusDot(it.application.status),
|
|
183
|
+
title: it.job?.company || '—',
|
|
184
|
+
sub: it.job?.position || '',
|
|
185
|
+
trailing: Badge(it.application.status, it.status_label),
|
|
186
|
+
onClick: it.job ? () => navigate(`/jobs/${it.job.id}`) : null,
|
|
187
|
+
}));
|
|
188
|
+
}
|
|
189
|
+
return Card({
|
|
190
|
+
title: '이번에 할 일',
|
|
191
|
+
actions: rows.length ? Btn('전체 보기', { sm: true, variant: 'ghost', onClick: () => navigate('/applications') }) : null,
|
|
192
|
+
body: rows.length
|
|
193
|
+
? el('div', {}, ...rows)
|
|
194
|
+
: el('p', { class: 'muted', style: { margin: 0 } }, '지금 처리할 일이 없어요. 새 공고를 분석해 보세요.'),
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/* ----------------------------------------------- recent jobs */
|
|
199
|
+
function RecentJobs(jobs) {
|
|
200
|
+
if (!jobs.length) {
|
|
201
|
+
return Card({
|
|
202
|
+
title: '최근 공고',
|
|
203
|
+
body: EmptyState({
|
|
204
|
+
iconName: 'briefcase', title: '저장된 공고가 아직 없어요',
|
|
205
|
+
action: Btn('공고 추가', { icon: 'plus', variant: 'ghost', onClick: () => navigate('/jobs') }),
|
|
206
|
+
}),
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
const rows = jobs.map((j) => ListRow({
|
|
210
|
+
title: j.company,
|
|
211
|
+
sub: j.position,
|
|
212
|
+
trailing: [
|
|
213
|
+
j.fit_score != null
|
|
214
|
+
? el('span', { class: `strong tnum ${scoreClass(j.fit_score)}` }, `${j.fit_score}점`)
|
|
215
|
+
: el('span', { class: 'muted text-sm' }, '—'),
|
|
216
|
+
Badge(j.status, j.status_label),
|
|
217
|
+
],
|
|
218
|
+
onClick: () => navigate(`/jobs/${j.id}`),
|
|
219
|
+
}));
|
|
220
|
+
return Card({
|
|
221
|
+
title: '최근 공고',
|
|
222
|
+
actions: Btn('전체 보기', { sm: true, variant: 'ghost', onClick: () => navigate('/jobs') }),
|
|
223
|
+
body: el('div', {}, ...rows),
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/* ----------------------------------------------- activity feed (day-bucketed) */
|
|
228
|
+
function Activity(acts) {
|
|
229
|
+
if (!acts.length) {
|
|
230
|
+
return Card({
|
|
231
|
+
title: '최근 활동',
|
|
232
|
+
body: el('p', { class: 'muted', style: { margin: 0 } }, '아직 활동 내역이 없어요. 공고를 저장하거나 분석하면 여기에 기록돼요.'),
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
const body = [];
|
|
236
|
+
for (const g of groupByDay(acts)) {
|
|
237
|
+
body.push(el('div', { class: 'feed-group' }, g.label));
|
|
238
|
+
for (const a of g.items) {
|
|
239
|
+
const m = ACTIVITY_META[a.type] || { icon: 'info', hue: 'slate' };
|
|
240
|
+
body.push(el('div', { class: 'feed-item' },
|
|
241
|
+
el('div', { class: 'feed-item__icon', style: { background: `var(--${m.hue}-soft)`, color: `var(--${m.hue})` } }, icon(m.icon)),
|
|
242
|
+
el('div', { class: 'feed-item__body' },
|
|
243
|
+
el('div', { class: 'feed-item__text' }, a.summary),
|
|
244
|
+
el('time', { class: 'feed-item__time', title: fmtDate(a.created_at), attrs: { datetime: a.created_at } }, fmtRelative(a.created_at)))));
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return Card({ title: '최근 활동', body: el('div', {}, ...body) });
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function groupByDay(acts) {
|
|
251
|
+
const midnight = (d) => { const x = new Date(d); x.setHours(0, 0, 0, 0); return x.getTime(); };
|
|
252
|
+
const today = midnight(new Date());
|
|
253
|
+
const yesterday = today - 86400000;
|
|
254
|
+
const order = [];
|
|
255
|
+
const buckets = new Map();
|
|
256
|
+
for (const a of acts) {
|
|
257
|
+
const day = midnight(a.created_at);
|
|
258
|
+
const label = day === today ? '오늘' : day === yesterday ? '어제' : fmtDate(a.created_at);
|
|
259
|
+
if (!buckets.has(label)) { buckets.set(label, []); order.push(label); }
|
|
260
|
+
buckets.get(label).push(a);
|
|
261
|
+
}
|
|
262
|
+
return order.map((label) => ({ label, items: buckets.get(label) }));
|
|
263
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// Interview — preparation materials for jobs that reached 서류 합격 or beyond.
|
|
2
|
+
import {
|
|
3
|
+
el, get, put, icon, navigate, Card, Badge, Btn, EmptyState, Field, Input,
|
|
4
|
+
Textarea, openModal, closeModal, toastOk, toastError, copyText,
|
|
5
|
+
downloadUrl, mount,
|
|
6
|
+
} from '/lib.js';
|
|
7
|
+
|
|
8
|
+
export async function render(ctx) {
|
|
9
|
+
const data = await get('/api/interview');
|
|
10
|
+
const preps = data.preps || [];
|
|
11
|
+
const eligible = data.eligible || [];
|
|
12
|
+
const prepByJob = {};
|
|
13
|
+
for (const p of preps) prepByJob[p.job_id] = p;
|
|
14
|
+
|
|
15
|
+
const wrap = el('div', { class: 'stack-4' });
|
|
16
|
+
|
|
17
|
+
if (!eligible.length) {
|
|
18
|
+
wrap.append(EmptyState({
|
|
19
|
+
iconName: 'mic',
|
|
20
|
+
title: '아직 면접 준비할 공고가 없어요',
|
|
21
|
+
body: '서류 합격 이상 상태가 되면 그 공고의 면접 준비를 여기서 할 수 있어요.',
|
|
22
|
+
action: Btn('지원 현황으로 가기', { icon: 'layers', variant: 'primary', onClick: () => navigate('/applications') }),
|
|
23
|
+
}));
|
|
24
|
+
mount(ctx.view, wrap);
|
|
25
|
+
ctx.setActions([]);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const list = el('div', { class: 'stack-4' },
|
|
30
|
+
...eligible.map((e) => JobCard(e, prepByJob[e.job.id])));
|
|
31
|
+
wrap.append(list);
|
|
32
|
+
|
|
33
|
+
mount(ctx.view, wrap);
|
|
34
|
+
ctx.setActions([]);
|
|
35
|
+
|
|
36
|
+
function JobCard(entry, prep) {
|
|
37
|
+
const job = entry.job || {};
|
|
38
|
+
const hasPrep = !!entry.has_prep && !!prep;
|
|
39
|
+
|
|
40
|
+
const body = [];
|
|
41
|
+
if (hasPrep) {
|
|
42
|
+
const qCount = (prep.questions || []).length;
|
|
43
|
+
const sCount = (prep.star_guides || []).length;
|
|
44
|
+
const hasIntro = !!(prep.self_introduction && prep.self_introduction.trim());
|
|
45
|
+
body.push(el('p', { class: 'text-secondary', style: { margin: 0 } },
|
|
46
|
+
`예상 질문 ${qCount}개 · STAR 가이드 ${sCount}개 · 1분 자기소개 ${hasIntro ? '있음' : '없음'}`));
|
|
47
|
+
} else {
|
|
48
|
+
body.push(el('p', { class: 'text-secondary', style: { margin: 0, lineHeight: '1.6' } },
|
|
49
|
+
'예상 질문과 1분 자기소개를 정리해 두면 한곳에서 연습할 수 있어요.'));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const actions = [];
|
|
53
|
+
if (hasPrep) {
|
|
54
|
+
actions.push(Btn('자세히 보기', { icon: 'external', sm: true, onClick: () => openPrepModal(entry, prep) }));
|
|
55
|
+
} else {
|
|
56
|
+
actions.push(Btn('직접 작성', { icon: 'edit', sm: true, onClick: () => openEditModal(entry, null) }));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return Card({
|
|
60
|
+
title: `${job.company || '—'} · ${job.position || ''}`,
|
|
61
|
+
sub: undefined,
|
|
62
|
+
actions: [Badge(entry.status, entry.status_label), ...actions],
|
|
63
|
+
body,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// -------------------------------------------------------- view full prep
|
|
68
|
+
function openPrepModal(entry, prep) {
|
|
69
|
+
const job = entry.job || {};
|
|
70
|
+
const jobId = prep.job_id;
|
|
71
|
+
const sections = el('div', { class: 'stack-4' });
|
|
72
|
+
|
|
73
|
+
// 예상 질문 & 꼬리 질문
|
|
74
|
+
const questions = prep.questions || [];
|
|
75
|
+
sections.append(Card({
|
|
76
|
+
title: '예상 질문 & 꼬리 질문',
|
|
77
|
+
body: questions.length
|
|
78
|
+
? el('div', { class: 'stack-3' }, ...questions.map(QuestionBlock))
|
|
79
|
+
: el('p', { class: 'muted', style: { margin: 0 } }, '등록된 예상 질문이 없습니다.'),
|
|
80
|
+
}));
|
|
81
|
+
|
|
82
|
+
// STAR 답변 가이드
|
|
83
|
+
const stars = prep.star_guides || [];
|
|
84
|
+
sections.append(Card({
|
|
85
|
+
title: 'STAR 답변 가이드',
|
|
86
|
+
body: stars.length
|
|
87
|
+
? el('div', { class: 'stack-3' }, ...stars.map(StarBlock))
|
|
88
|
+
: el('p', { class: 'muted', style: { margin: 0 } }, '등록된 STAR 가이드가 없습니다.'),
|
|
89
|
+
}));
|
|
90
|
+
|
|
91
|
+
// 1분 자기소개
|
|
92
|
+
const intro = prep.self_introduction || '';
|
|
93
|
+
sections.append(Card({
|
|
94
|
+
title: '1분 자기소개',
|
|
95
|
+
actions: intro.trim() && Btn('복사', { icon: 'copy', sm: true, onClick: () => copyText(intro) }),
|
|
96
|
+
body: intro.trim()
|
|
97
|
+
? el('div', { class: 'doc-preview' }, intro)
|
|
98
|
+
: el('p', { class: 'muted', style: { margin: 0 } }, '작성된 자기소개가 없습니다.'),
|
|
99
|
+
}));
|
|
100
|
+
|
|
101
|
+
// 메모
|
|
102
|
+
if (prep.notes && prep.notes.trim()) {
|
|
103
|
+
sections.append(Card({
|
|
104
|
+
title: '메모',
|
|
105
|
+
body: el('div', { class: 'doc-preview' }, prep.notes),
|
|
106
|
+
}));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
openModal({
|
|
110
|
+
title: `${job.company || ''} 면접 준비`,
|
|
111
|
+
size: 'lg',
|
|
112
|
+
body: sections,
|
|
113
|
+
footer: (close) => [
|
|
114
|
+
Btn('MD 내보내기', { icon: 'download', onClick: () => downloadUrl(`/api/export/interview/${jobId}?format=md`) }),
|
|
115
|
+
Btn('HTML 내보내기', { icon: 'download', onClick: () => downloadUrl(`/api/export/interview/${jobId}?format=html`) }),
|
|
116
|
+
Btn('수정', { icon: 'edit', variant: 'primary', onClick: () => { close(); openEditModal(entry, prep); } }),
|
|
117
|
+
],
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function QuestionBlock(q) {
|
|
122
|
+
const node = el('div', { class: 'stack-2', style: { paddingBottom: '6px' } });
|
|
123
|
+
node.append(el('div', { class: 'strong' }, q.question || ''));
|
|
124
|
+
if (q.intent) node.append(el('div', { class: 'muted text-sm' }, `의도: ${q.intent}`));
|
|
125
|
+
const followups = (q.followups || []).filter((f) => f && String(f).trim());
|
|
126
|
+
if (followups.length) {
|
|
127
|
+
node.append(el('div', { class: 'text-sm text-secondary', style: { marginTop: '2px' } }, '꼬리 질문'));
|
|
128
|
+
node.append(el('ul', { class: 'stack-2', style: { margin: '2px 0 0', paddingLeft: '18px' } },
|
|
129
|
+
...followups.map((f) => el('li', { class: 'text-sm text-secondary' }, f))));
|
|
130
|
+
}
|
|
131
|
+
if (q.answer_outline && String(q.answer_outline).trim()) {
|
|
132
|
+
node.append(el('div', { class: 'muted text-sm', style: { marginTop: '4px' } }, '답변 가이드'));
|
|
133
|
+
node.append(el('div', { class: 'doc-preview', style: { marginTop: '2px' } }, q.answer_outline));
|
|
134
|
+
}
|
|
135
|
+
return node;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function StarBlock(g) {
|
|
139
|
+
const rows = [];
|
|
140
|
+
const add = (label, val) => { if (val && String(val).trim()) { rows.push(el('dt', {}, label)); rows.push(el('dd', {}, val)); } };
|
|
141
|
+
add('상황', g.situation);
|
|
142
|
+
add('과제', g.task);
|
|
143
|
+
add('행동', g.action);
|
|
144
|
+
add('결과', g.result);
|
|
145
|
+
return el('div', { class: 'stack-2', style: { paddingBottom: '6px' } },
|
|
146
|
+
el('div', { class: 'strong' }, g.question || ''),
|
|
147
|
+
rows.length
|
|
148
|
+
? el('dl', { class: 'kv' }, ...rows)
|
|
149
|
+
: el('div', { class: 'muted text-sm' }, 'S/T/A/R 내용이 비어 있습니다.'));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// -------------------------------------------------- manual edit / create
|
|
153
|
+
function openEditModal(entry, prep) {
|
|
154
|
+
const job = entry.job || {};
|
|
155
|
+
const jobId = job.id;
|
|
156
|
+
prep = prep || {};
|
|
157
|
+
|
|
158
|
+
const introInput = Textarea({
|
|
159
|
+
value: prep.self_introduction || '',
|
|
160
|
+
rows: 5,
|
|
161
|
+
placeholder: '1분 분량(약 300~400자)의 자기소개를 작성하세요.',
|
|
162
|
+
});
|
|
163
|
+
const notesInput = Textarea({
|
|
164
|
+
value: prep.notes || '',
|
|
165
|
+
rows: 3,
|
|
166
|
+
placeholder: '면접 준비 메모를 자유롭게 적어두세요.',
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// Repeatable question list: question + answer_outline.
|
|
170
|
+
const qList = el('div', { class: 'stack-3' });
|
|
171
|
+
const qRows = [];
|
|
172
|
+
function addQuestionRow(q) {
|
|
173
|
+
q = q || {};
|
|
174
|
+
const qInput = Input({ value: q.question || '', placeholder: '예상 질문' });
|
|
175
|
+
const aInput = Textarea({ value: q.answer_outline || '', rows: 2, placeholder: '답변 가이드 (선택)' });
|
|
176
|
+
const row = el('div', { class: 'subcard stack-2' },
|
|
177
|
+
el('div', { class: 'flex between center' },
|
|
178
|
+
el('span', { class: 'text-sm muted' }, `질문 ${qRows.length + 1}`),
|
|
179
|
+
Btn('삭제', { icon: 'trash', sm: true, variant: 'ghost', onClick: () => { entryRef.removed = true; row.remove(); } })),
|
|
180
|
+
Field('질문', qInput),
|
|
181
|
+
Field('답변 가이드', aInput));
|
|
182
|
+
const entryRef = { qInput, aInput, removed: false };
|
|
183
|
+
qRows.push(entryRef);
|
|
184
|
+
qList.append(row);
|
|
185
|
+
}
|
|
186
|
+
(prep.questions || []).forEach(addQuestionRow);
|
|
187
|
+
|
|
188
|
+
const formBody = el('div', { class: 'stack-4' },
|
|
189
|
+
Field('1분 자기소개', introInput),
|
|
190
|
+
Field('메모', notesInput),
|
|
191
|
+
el('div', { class: 'stack-2' },
|
|
192
|
+
el('div', { class: 'flex between center' },
|
|
193
|
+
el('label', {}, '예상 질문'),
|
|
194
|
+
Btn('질문 추가', { icon: 'plus', sm: true, onClick: () => addQuestionRow(null) })),
|
|
195
|
+
qList));
|
|
196
|
+
|
|
197
|
+
openModal({
|
|
198
|
+
title: `${job.company || ''} 면접 준비 ${Object.keys(prep).length ? '수정' : '작성'}`,
|
|
199
|
+
size: 'lg',
|
|
200
|
+
body: formBody,
|
|
201
|
+
footer: (close) => [
|
|
202
|
+
Btn('취소', { onClick: close }),
|
|
203
|
+
Btn('면접 준비 저장', { variant: 'primary', onClick: () => save(jobId) }),
|
|
204
|
+
],
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
async function save(id) {
|
|
208
|
+
const questions = qRows
|
|
209
|
+
.filter((r) => !r.removed)
|
|
210
|
+
.map((r) => ({ question: r.qInput.value.trim(), answer_outline: r.aInput.value.trim() }))
|
|
211
|
+
.filter((q) => q.question);
|
|
212
|
+
// Preserve any richer star_guides authored elsewhere (AI/MCP).
|
|
213
|
+
const payload = {
|
|
214
|
+
questions,
|
|
215
|
+
star_guides: prep.star_guides || [],
|
|
216
|
+
self_introduction: introInput.value,
|
|
217
|
+
notes: notesInput.value,
|
|
218
|
+
};
|
|
219
|
+
try {
|
|
220
|
+
await put(`/api/jobs/${id}/interview`, payload);
|
|
221
|
+
closeModal();
|
|
222
|
+
toastOk('면접 준비를 저장했어요.');
|
|
223
|
+
await ctx.refreshNav();
|
|
224
|
+
await render(ctx);
|
|
225
|
+
} catch (err) {
|
|
226
|
+
toastError(err);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|