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,494 @@
|
|
|
1
|
+
// Jobs — list of saved postings (#/jobs) and the rich per-job detail (#/jobs/:id).
|
|
2
|
+
// Detail is the densest screen in the app: posting info, fit analysis, linked
|
|
3
|
+
// cover letters, interview prep, and related history, all driven by the AI's
|
|
4
|
+
// saved data with manual fallbacks throughout.
|
|
5
|
+
import {
|
|
6
|
+
el, get, post, put, del, icon, navigate, Card, Badge, Btn,
|
|
7
|
+
EmptyState, Chips, Field, Input, Textarea, Select,
|
|
8
|
+
openModal, closeModal, confirmDialog, toastOk, toastError,
|
|
9
|
+
fmtDate, scoreClass, mount, meta,
|
|
10
|
+
} from '/lib.js';
|
|
11
|
+
|
|
12
|
+
export async function render(ctx) {
|
|
13
|
+
if (ctx.params[0]) return renderDetail(ctx, ctx.params[0]);
|
|
14
|
+
return renderList(ctx);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/* =============================================================== List mode */
|
|
18
|
+
|
|
19
|
+
async function renderList(ctx) {
|
|
20
|
+
const { jobs } = await get('/api/jobs');
|
|
21
|
+
const wrap = el('div', { class: 'stack-4' });
|
|
22
|
+
|
|
23
|
+
if (!jobs.length) {
|
|
24
|
+
ctx.setActions([]); // 단일 1차 액션은 빈 상태에만 둔다
|
|
25
|
+
wrap.append(EmptyState({
|
|
26
|
+
iconName: 'briefcase',
|
|
27
|
+
title: '저장된 공고가 없어요',
|
|
28
|
+
body: '관심 있는 채용공고를 추가해 적합도와 진행 상태를 관리해 보세요.',
|
|
29
|
+
action: Btn('공고 추가', { icon: 'plus', variant: 'primary', onClick: () => openJobModal(ctx) }),
|
|
30
|
+
}));
|
|
31
|
+
mount(ctx.view, wrap);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const addBtn = Btn('공고 추가', { icon: 'plus', variant: 'primary', sm: true, onClick: () => openJobModal(ctx) });
|
|
36
|
+
const search = Input({ type: 'search', placeholder: '회사·직무 검색', attrs: { 'aria-label': '공고 검색' } });
|
|
37
|
+
search.classList.add('input--inline');
|
|
38
|
+
ctx.setActions([search, addBtn]);
|
|
39
|
+
|
|
40
|
+
const rowData = jobs.map((j) => ({
|
|
41
|
+
tr: el('tr', { class: 'is-clickable', onClick: () => navigate(`/jobs/${j.id}`) },
|
|
42
|
+
el('td', {},
|
|
43
|
+
el('div', { class: 'strong' }, j.company),
|
|
44
|
+
el('div', { class: 'muted text-sm' }, j.position)),
|
|
45
|
+
el('td', {},
|
|
46
|
+
el('div', {}, j.location || el('span', { class: 'muted' }, '위치 미정')),
|
|
47
|
+
el('div', { class: 'muted text-sm' }, j.deadline ? `마감 ${fmtDate(j.deadline)}` : '마감일 없음')),
|
|
48
|
+
el('td', { style: { textAlign: 'right' } },
|
|
49
|
+
j.fit_score != null
|
|
50
|
+
? el('span', { class: `strong tnum ${scoreClass(j.fit_score)}` }, `${j.fit_score}점`)
|
|
51
|
+
: el('span', { class: 'muted' }, '—')),
|
|
52
|
+
el('td', {}, Badge(j.status, j.status_label))),
|
|
53
|
+
text: `${j.company} ${j.position} ${j.location || ''} ${(j.keywords || []).join(' ')}`.toLowerCase(),
|
|
54
|
+
}));
|
|
55
|
+
|
|
56
|
+
const tbody = el('tbody', {}, ...rowData.map((r) => r.tr));
|
|
57
|
+
const noResult = el('tr', { class: 'hide' },
|
|
58
|
+
el('td', { attrs: { colspan: '4' }, class: 'muted', style: { textAlign: 'center', padding: '24px 16px' } }, '검색 결과가 없어요.'));
|
|
59
|
+
tbody.append(noResult);
|
|
60
|
+
|
|
61
|
+
wrap.append(Card({
|
|
62
|
+
title: '저장된 공고',
|
|
63
|
+
sub: `${jobs.length}개`,
|
|
64
|
+
body: el('table', { class: 'table' },
|
|
65
|
+
el('thead', {}, el('tr', {},
|
|
66
|
+
el('th', {}, '회사 · 직무'),
|
|
67
|
+
el('th', {}, '위치 · 마감일'),
|
|
68
|
+
el('th', { style: { textAlign: 'right' } }, '적합도'),
|
|
69
|
+
el('th', {}, '상태'))),
|
|
70
|
+
tbody),
|
|
71
|
+
}));
|
|
72
|
+
|
|
73
|
+
mount(ctx.view, wrap);
|
|
74
|
+
|
|
75
|
+
search.addEventListener('input', () => {
|
|
76
|
+
const q = search.value.trim().toLowerCase();
|
|
77
|
+
let shown = 0;
|
|
78
|
+
for (const r of rowData) { const m = !q || r.text.includes(q); r.tr.classList.toggle('hide', !m); if (m) shown += 1; }
|
|
79
|
+
noResult.classList.toggle('hide', shown !== 0);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/* ---------------------------------------------------------- add/edit modal */
|
|
84
|
+
|
|
85
|
+
function linesToArray(str) {
|
|
86
|
+
return (str || '').split('\n').map((s) => s.trim()).filter(Boolean);
|
|
87
|
+
}
|
|
88
|
+
function csvToArray(str) {
|
|
89
|
+
return (str || '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Build a job form (used for both create and edit). `job` is the existing
|
|
94
|
+
* record when editing, or null when creating. `onSaved(result)` receives the
|
|
95
|
+
* API response so callers can navigate/re-render appropriately.
|
|
96
|
+
*/
|
|
97
|
+
function jobForm(ctx, { job, onSaved }) {
|
|
98
|
+
const v = job || {};
|
|
99
|
+
const company = Input({ value: v.company || '', placeholder: '회사명', attrs: { required: 'required' } });
|
|
100
|
+
const position = Input({ value: v.position || '', placeholder: '직무 / 포지션명', attrs: { required: 'required' } });
|
|
101
|
+
const url = Input({ value: v.url || '', placeholder: 'https://…', type: 'url' });
|
|
102
|
+
const location = Input({ value: v.location || '', placeholder: '서울 / 재택 등' });
|
|
103
|
+
const employment = Input({ value: v.employment_type || '', placeholder: '정규직 / 계약직 / 인턴 등' });
|
|
104
|
+
const deadline = Input({ value: v.deadline || '', placeholder: 'YYYY-MM-DD' });
|
|
105
|
+
const source = Input({ value: v.source || '', placeholder: '사람인 / 원티드 / 직접 입력 등' });
|
|
106
|
+
const description = Textarea({
|
|
107
|
+
value: v.description || '', placeholder: '공고 원문 또는 정리된 내용을 붙여넣으세요.',
|
|
108
|
+
style: { minHeight: '160px' },
|
|
109
|
+
});
|
|
110
|
+
const keywords = Input({ value: (v.keywords || []).join(', '), placeholder: '핵심 키워드 (쉼표로 구분)' });
|
|
111
|
+
const requirements = Textarea({
|
|
112
|
+
value: (v.requirements || []).join('\n'), placeholder: '자격요건/우대사항을 한 줄에 하나씩',
|
|
113
|
+
style: { minHeight: '90px' },
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// "붙여넣은 텍스트 정리" — server-side clean + best-effort field extraction.
|
|
117
|
+
const tidyBtn = Btn('붙여넣은 텍스트 정리', {
|
|
118
|
+
icon: 'sparkle', sm: true, variant: 'ghost',
|
|
119
|
+
onClick: async () => {
|
|
120
|
+
const raw = description.value.trim();
|
|
121
|
+
if (!raw) { toastError('먼저 공고 원문을 붙여넣어 주세요.'); return; }
|
|
122
|
+
tidyBtn.disabled = true;
|
|
123
|
+
try {
|
|
124
|
+
const r = await post('/api/parse/job', { raw });
|
|
125
|
+
if (r.text) description.value = r.text;
|
|
126
|
+
if (r.company && !company.value.trim()) company.value = r.company;
|
|
127
|
+
if (r.position && !position.value.trim()) position.value = r.position;
|
|
128
|
+
if (r.deadline && !deadline.value.trim()) deadline.value = r.deadline;
|
|
129
|
+
if (r.keywords && r.keywords.length) {
|
|
130
|
+
const existing = csvToArray(keywords.value);
|
|
131
|
+
const merged = [...new Set([...existing, ...r.keywords])];
|
|
132
|
+
keywords.value = merged.join(', ');
|
|
133
|
+
}
|
|
134
|
+
toastOk('붙여넣은 텍스트를 정리하고 일부 항목을 채웠어요.');
|
|
135
|
+
} catch (e) { toastError(e); }
|
|
136
|
+
finally { tidyBtn.disabled = false; }
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const form = el('form', { class: 'stack-3', onSubmit: submit },
|
|
141
|
+
el('div', { class: 'grid grid--2' },
|
|
142
|
+
Field('회사 *', company),
|
|
143
|
+
Field('직무 *', position)),
|
|
144
|
+
Field('공고 URL', url),
|
|
145
|
+
el('div', { class: 'grid grid--3' },
|
|
146
|
+
Field('위치', location),
|
|
147
|
+
Field('고용형태', employment),
|
|
148
|
+
Field('마감일', deadline, 'YYYY-MM-DD')),
|
|
149
|
+
Field('출처', source),
|
|
150
|
+
Field(
|
|
151
|
+
el('div', { class: 'flex between center' }, el('span', {}, '공고 원문'), tidyBtn),
|
|
152
|
+
description,
|
|
153
|
+
'원문을 붙여넣고 "정리"를 누르면 회사·직무·키워드·마감일을 자동으로 채워봅니다.'),
|
|
154
|
+
Field('핵심 키워드', keywords, '쉼표로 구분'),
|
|
155
|
+
Field('자격요건 · 우대사항', requirements, '한 줄에 하나씩'),
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
async function submit(e) {
|
|
159
|
+
if (e) e.preventDefault();
|
|
160
|
+
const body = {
|
|
161
|
+
company: company.value.trim(),
|
|
162
|
+
position: position.value.trim(),
|
|
163
|
+
url: url.value.trim() || undefined,
|
|
164
|
+
location: location.value.trim() || undefined,
|
|
165
|
+
employment_type: employment.value.trim() || undefined,
|
|
166
|
+
deadline: deadline.value.trim() || undefined,
|
|
167
|
+
source: source.value.trim() || undefined,
|
|
168
|
+
description: description.value.trim() || undefined,
|
|
169
|
+
keywords: csvToArray(keywords.value),
|
|
170
|
+
requirements: linesToArray(requirements.value),
|
|
171
|
+
};
|
|
172
|
+
if (!body.company || !body.position) {
|
|
173
|
+
toastError('회사와 직무는 필수입니다.');
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
try {
|
|
177
|
+
const res = job
|
|
178
|
+
? await put(`/api/jobs/${job.id}`, body)
|
|
179
|
+
: await post('/api/jobs', body);
|
|
180
|
+
toastOk(job ? '공고를 수정했습니다.' : '공고를 저장했습니다.');
|
|
181
|
+
closeModal();
|
|
182
|
+
await onSaved(res);
|
|
183
|
+
} catch (err) { toastError(err); }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return { form, submit };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function openJobModal(ctx) {
|
|
190
|
+
const built = jobForm(ctx, {
|
|
191
|
+
job: null,
|
|
192
|
+
onSaved: async (res) => {
|
|
193
|
+
await ctx.refreshNav();
|
|
194
|
+
navigate(`/jobs/${res.job.id}`);
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
openModal({
|
|
198
|
+
title: '공고 추가',
|
|
199
|
+
size: 'lg',
|
|
200
|
+
body: built.form,
|
|
201
|
+
footer: (close) => [
|
|
202
|
+
Btn('취소', { onClick: close }),
|
|
203
|
+
Btn('공고 저장', { variant: 'primary', onClick: built.submit }),
|
|
204
|
+
],
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function openEditModal(ctx, job, remount) {
|
|
209
|
+
const built = jobForm(ctx, {
|
|
210
|
+
job,
|
|
211
|
+
onSaved: async () => { await ctx.refreshNav(); await remount(); },
|
|
212
|
+
});
|
|
213
|
+
openModal({
|
|
214
|
+
title: '공고 수정',
|
|
215
|
+
size: 'lg',
|
|
216
|
+
body: built.form,
|
|
217
|
+
footer: (close) => [
|
|
218
|
+
Btn('취소', { onClick: close }),
|
|
219
|
+
Btn('공고 저장', { variant: 'primary', onClick: built.submit }),
|
|
220
|
+
],
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/* ============================================================= Detail mode */
|
|
225
|
+
|
|
226
|
+
async function renderDetail(ctx, jobId) {
|
|
227
|
+
let job;
|
|
228
|
+
try {
|
|
229
|
+
({ job } = await get(`/api/jobs/${jobId}`));
|
|
230
|
+
} catch (err) {
|
|
231
|
+
ctx.setTitle('공고를 찾을 수 없음');
|
|
232
|
+
const notFound = el('div', { class: 'stack-4' },
|
|
233
|
+
EmptyState({
|
|
234
|
+
iconName: 'briefcase',
|
|
235
|
+
title: '공고를 찾을 수 없어요',
|
|
236
|
+
body: err instanceof Error ? err.message : '요청한 공고가 삭제되었거나 존재하지 않습니다.',
|
|
237
|
+
action: Btn('공고 목록으로', { icon: 'chevronRight', variant: 'primary', onClick: () => navigate('/jobs') }),
|
|
238
|
+
}));
|
|
239
|
+
mount(ctx.view, notFound);
|
|
240
|
+
ctx.setActions(null);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const remount = () => renderDetail(ctx, jobId);
|
|
245
|
+
const m = await meta();
|
|
246
|
+
|
|
247
|
+
ctx.setTitle(`${job.company} · ${job.position}`);
|
|
248
|
+
|
|
249
|
+
// --- Header actions (also mirrored into the topbar) -----------------------
|
|
250
|
+
const statusSelect = Select(
|
|
251
|
+
m.statuses.map((s) => ({ value: s.value, label: s.label, selected: s.value === job.status })),
|
|
252
|
+
{
|
|
253
|
+
onChange: async (e) => {
|
|
254
|
+
const status = e.target.value;
|
|
255
|
+
if (status === job.status) return;
|
|
256
|
+
try {
|
|
257
|
+
const res = await put(`/api/applications/${jobId}/status`, { status });
|
|
258
|
+
toastOk('상태를 변경했습니다.');
|
|
259
|
+
if (res && res.hint) toastOk(res.hint);
|
|
260
|
+
await ctx.refreshNav();
|
|
261
|
+
await remount();
|
|
262
|
+
} catch (err) { toastError(err); e.target.value = job.status; }
|
|
263
|
+
},
|
|
264
|
+
},
|
|
265
|
+
);
|
|
266
|
+
statusSelect.classList.add('select--sm');
|
|
267
|
+
|
|
268
|
+
const editBtn = Btn('수정', { icon: 'edit', sm: true, variant: 'ghost', onClick: () => openEditModal(ctx, job, remount) });
|
|
269
|
+
const delBtn = Btn('삭제', {
|
|
270
|
+
icon: 'trash', sm: true, variant: 'danger',
|
|
271
|
+
onClick: async () => {
|
|
272
|
+
const ok = await confirmDialog({
|
|
273
|
+
title: '공고 삭제',
|
|
274
|
+
message: `'${job.company} · ${job.position}' 공고를 삭제할까요? 연결된 지원 기록도 함께 사라집니다.`,
|
|
275
|
+
confirmLabel: '삭제', danger: true,
|
|
276
|
+
});
|
|
277
|
+
if (!ok) return;
|
|
278
|
+
try {
|
|
279
|
+
await del(`/api/jobs/${jobId}`);
|
|
280
|
+
toastOk('공고를 삭제했습니다.');
|
|
281
|
+
await ctx.refreshNav();
|
|
282
|
+
navigate('/jobs');
|
|
283
|
+
} catch (err) { toastError(err); }
|
|
284
|
+
},
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
ctx.setActions([
|
|
288
|
+
Btn('목록', { icon: 'chevronRight', sm: true, variant: 'ghost', onClick: () => navigate('/jobs') }),
|
|
289
|
+
editBtn,
|
|
290
|
+
]);
|
|
291
|
+
|
|
292
|
+
// --- Page head ------------------------------------------------------------
|
|
293
|
+
// 수정은 상단바(ctx.setActions)에만 둔다 — 여기엔 상태 변경·원문·삭제만.
|
|
294
|
+
const headActions = el('div', { class: 'flex gap-2 center wrap' },
|
|
295
|
+
Field('상태', statusSelect),
|
|
296
|
+
job.url ? el('a', { class: 'btn btn--ghost btn--sm', href: job.url, attrs: { target: '_blank', rel: 'noopener noreferrer' } }, icon('external'), el('span', {}, '공고 원문 열기')) : null,
|
|
297
|
+
delBtn,
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
const head = el('div', { class: 'page-head' },
|
|
301
|
+
el('div', { class: 'page-head__text' },
|
|
302
|
+
el('div', { class: 'flex gap-3 center wrap' },
|
|
303
|
+
el('h1', {}, job.company),
|
|
304
|
+
Badge(job.status, job.status_label)),
|
|
305
|
+
el('p', {}, job.position)),
|
|
306
|
+
el('div', { class: 'page-head__actions' }, headActions),
|
|
307
|
+
);
|
|
308
|
+
|
|
309
|
+
// --- Layout: two columns (info+fit left, side cards right) ----------------
|
|
310
|
+
const left = el('div', { class: 'stack-4' },
|
|
311
|
+
PostingCard(job),
|
|
312
|
+
FitCard(job.fit),
|
|
313
|
+
);
|
|
314
|
+
const right = el('div', { class: 'stack-4' },
|
|
315
|
+
CoverLettersCard(job.cover_letters),
|
|
316
|
+
InterviewCard(job),
|
|
317
|
+
RelatedCard(job.related),
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
const wrap = el('div', { class: 'stack-4' },
|
|
321
|
+
head,
|
|
322
|
+
el('div', { class: 'grid grid--2' }, left, right),
|
|
323
|
+
);
|
|
324
|
+
mount(ctx.view, wrap);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/* ----------------------------------------------------------- detail cards */
|
|
328
|
+
|
|
329
|
+
function PostingCard(job) {
|
|
330
|
+
const body = [];
|
|
331
|
+
|
|
332
|
+
body.push(el('dl', { class: 'kv' },
|
|
333
|
+
el('dt', {}, '위치'), el('dd', {}, job.location || el('span', { class: 'muted' }, '미정')),
|
|
334
|
+
el('dt', {}, '고용형태'), el('dd', {}, job.employment_type || el('span', { class: 'muted' }, '미정')),
|
|
335
|
+
el('dt', {}, '마감일'), el('dd', {}, job.deadline ? fmtDate(job.deadline) : el('span', { class: 'muted' }, '없음')),
|
|
336
|
+
el('dt', {}, '출처'), el('dd', {}, job.source || el('span', { class: 'muted' }, '직접 입력')),
|
|
337
|
+
));
|
|
338
|
+
|
|
339
|
+
if (job.keywords && job.keywords.length) {
|
|
340
|
+
body.push(el('div', { class: 'mt-3' },
|
|
341
|
+
el('div', { class: 'muted text-sm mb-2' }, '핵심 키워드'),
|
|
342
|
+
Chips(job.keywords, { accent: true })));
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (job.requirements && job.requirements.length) {
|
|
346
|
+
body.push(el('div', { class: 'mt-3' },
|
|
347
|
+
el('div', { class: 'muted text-sm mb-2' }, '자격요건 · 우대사항'),
|
|
348
|
+
el('ul', { class: 'stack-2', style: { margin: 0, paddingLeft: '18px' } },
|
|
349
|
+
...job.requirements.map((r) => el('li', {}, r)))));
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (job.description) {
|
|
353
|
+
body.push(el('div', { class: 'mt-3' },
|
|
354
|
+
el('div', { class: 'muted text-sm mb-2' }, '공고 원문'),
|
|
355
|
+
el('div', { class: 'doc-preview' }, job.description)));
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return Card({ title: '공고 정보', body });
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function FitCard(fit) {
|
|
362
|
+
if (!fit) {
|
|
363
|
+
return Card({
|
|
364
|
+
title: '적합도 분석',
|
|
365
|
+
body: EmptyState({
|
|
366
|
+
iconName: 'target',
|
|
367
|
+
title: '적합도 분석 전이에요',
|
|
368
|
+
body: '이 공고와 내 프로필을 비교한 분석 결과가 여기에 표시됩니다.',
|
|
369
|
+
}),
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const body = [];
|
|
374
|
+
|
|
375
|
+
body.push(el('div', { class: 'flex gap-4 center' },
|
|
376
|
+
fit.score != null
|
|
377
|
+
? el('div', { class: `tnum ${scoreClass(fit.score)}`, style: { fontSize: '40px', fontWeight: '700', lineHeight: '1' } }, String(fit.score))
|
|
378
|
+
: el('div', { class: 'muted', style: { fontSize: '28px' } }, '—'),
|
|
379
|
+
el('div', { class: 'muted text-sm' }, fit.score != null ? '종합 적합도 (100점 만점)' : '점수 미산정'),
|
|
380
|
+
));
|
|
381
|
+
|
|
382
|
+
if (fit.summary) {
|
|
383
|
+
body.push(el('p', { class: 'text-secondary', style: { lineHeight: '1.6' } }, fit.summary));
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (fit.strengths && fit.strengths.length) {
|
|
387
|
+
body.push(el('div', {},
|
|
388
|
+
el('div', { class: 'muted text-sm mb-2' }, '강점'),
|
|
389
|
+
el('div', { class: 'stack-2' },
|
|
390
|
+
...fit.strengths.map((s) => el('div', { class: 'flex gap-2', style: { alignItems: 'flex-start' } },
|
|
391
|
+
icon('check', 'score-strong'),
|
|
392
|
+
el('span', { class: 'text-secondary' }, s))))));
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (fit.gaps && fit.gaps.length) {
|
|
396
|
+
body.push(el('div', {},
|
|
397
|
+
el('div', { class: 'muted text-sm mb-2' }, '보완할 부분'),
|
|
398
|
+
el('ul', { class: 'stack-2', style: { margin: 0, paddingLeft: '18px' } },
|
|
399
|
+
...fit.gaps.map((g) => el('li', { class: 'text-secondary' }, g)))));
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if (fit.matched_keywords && fit.matched_keywords.length) {
|
|
403
|
+
body.push(el('div', {},
|
|
404
|
+
el('div', { class: 'muted text-sm mb-2' }, '일치하는 키워드'),
|
|
405
|
+
Chips(fit.matched_keywords, { accent: true })));
|
|
406
|
+
}
|
|
407
|
+
if (fit.missing_keywords && fit.missing_keywords.length) {
|
|
408
|
+
body.push(el('div', {},
|
|
409
|
+
el('div', { class: 'muted text-sm mb-2' }, '부족한 키워드'),
|
|
410
|
+
Chips(fit.missing_keywords)));
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (fit.recommendations && fit.recommendations.length) {
|
|
414
|
+
body.push(el('div', {},
|
|
415
|
+
el('div', { class: 'muted text-sm mb-2' }, '추천 전략'),
|
|
416
|
+
el('ul', { class: 'stack-2', style: { margin: 0, paddingLeft: '18px' } },
|
|
417
|
+
...fit.recommendations.map((r) => el('li', { class: 'text-secondary' }, r)))));
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
return Card({ title: '적합도 분석', body: el('div', { class: 'stack-3' }, ...body) });
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function CoverLettersCard(coverLetters) {
|
|
424
|
+
const list = coverLetters || [];
|
|
425
|
+
if (!list.length) {
|
|
426
|
+
return Card({
|
|
427
|
+
title: '연결된 자기소개서',
|
|
428
|
+
body: el('p', { class: 'muted', style: { margin: 0, lineHeight: '1.6' } },
|
|
429
|
+
'아직 연결된 자기소개서가 없어요. 문서 탭에서 작성하거나 붙여넣어 보관하세요.'),
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
return Card({
|
|
433
|
+
title: '연결된 자기소개서',
|
|
434
|
+
sub: `${list.length}개`,
|
|
435
|
+
body: el('div', { class: 'stack-2' },
|
|
436
|
+
...list.map((cl) => el('div', {
|
|
437
|
+
class: 'flex between center is-clickable',
|
|
438
|
+
style: { padding: '8px 0', cursor: 'pointer' },
|
|
439
|
+
onClick: () => navigate('/documents'),
|
|
440
|
+
},
|
|
441
|
+
el('div', { class: 'flex gap-2 center' },
|
|
442
|
+
icon('file', 'muted'),
|
|
443
|
+
el('div', {},
|
|
444
|
+
el('div', { class: 'strong' }, cl.title),
|
|
445
|
+
el('div', { class: 'muted text-sm' }, `버전 ${cl.version_count ?? 1}개`))),
|
|
446
|
+
icon('chevronRight', 'muted')))),
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function InterviewCard(job) {
|
|
451
|
+
const interview = job.interview;
|
|
452
|
+
if (interview) {
|
|
453
|
+
const count = (interview.questions || []).length;
|
|
454
|
+
return Card({
|
|
455
|
+
title: '면접 준비',
|
|
456
|
+
actions: Btn('면접 준비 보기', { sm: true, variant: 'ghost', onClick: () => navigate('/interview') }),
|
|
457
|
+
body: el('div', { class: 'stack-2' },
|
|
458
|
+
el('div', { class: 'flex gap-2 center' },
|
|
459
|
+
icon('mic', 'muted'),
|
|
460
|
+
el('span', { class: 'text-secondary' }, count ? `예상 질문 ${count}개가 준비되어 있어요.` : '면접 준비 자료가 저장되어 있어요.')),
|
|
461
|
+
interview.self_introduction
|
|
462
|
+
? el('div', { class: 'muted text-sm' }, '1분 자기소개 초안도 함께 저장되어 있습니다.')
|
|
463
|
+
: null),
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const eligible = ['document_passed', 'interview', 'final_passed'].includes(job.status);
|
|
468
|
+
return Card({
|
|
469
|
+
title: '면접 준비',
|
|
470
|
+
body: el('p', { class: 'muted', style: { margin: 0, lineHeight: '1.6' } },
|
|
471
|
+
eligible
|
|
472
|
+
? '서류 단계를 통과했어요. 면접 준비 탭에서 예상 질문과 1분 자기소개를 정리할 수 있어요.'
|
|
473
|
+
: '서류 합격 이후 단계가 되면 이 공고 기준으로 면접 준비를 안내해 드려요.'),
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function RelatedCard(related) {
|
|
478
|
+
const list = related || [];
|
|
479
|
+
if (!list.length) return null;
|
|
480
|
+
return Card({
|
|
481
|
+
title: '관련 기록',
|
|
482
|
+
sub: '같은 회사 · 직무',
|
|
483
|
+
body: el('div', { class: 'stack-2' },
|
|
484
|
+
...list.map((r) => el('div', {
|
|
485
|
+
class: 'flex between center is-clickable',
|
|
486
|
+
style: { padding: '8px 0', cursor: 'pointer' },
|
|
487
|
+
onClick: () => navigate(`/jobs/${r.id}`),
|
|
488
|
+
},
|
|
489
|
+
el('div', {},
|
|
490
|
+
el('div', { class: 'strong' }, r.position),
|
|
491
|
+
el('div', { class: 'muted text-sm' }, [r.company, r.deadline ? `마감 ${fmtDate(r.deadline)}` : null].filter(Boolean).join(' · '))),
|
|
492
|
+
Badge(r.status, r.status_label)))),
|
|
493
|
+
});
|
|
494
|
+
}
|