careermate 0.1.1 → 0.1.3
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/LICENSE +21 -0
- package/README.md +14 -10
- package/THIRD_PARTY_NOTICES.md +15 -0
- package/apps/mcp/src/index.ts +4 -5
- package/apps/web/demo/demo-shim.js +536 -0
- package/apps/web/demo/demo.css +75 -0
- package/apps/web/demo/seed.js +269 -0
- package/apps/web/public/app.js +34 -2
- package/apps/web/public/index.html +10 -2
- package/apps/web/public/lib.js +134 -22
- package/apps/web/public/pages/applications.js +2 -9
- package/apps/web/public/pages/documents.js +73 -52
- package/apps/web/public/pages/home.js +16 -18
- package/apps/web/public/pages/interview.js +9 -12
- package/apps/web/public/pages/jobs.js +40 -41
- package/apps/web/public/pages/profile.js +69 -89
- package/apps/web/public/pages/settings.js +15 -10
- package/apps/web/public/styles.css +63 -4
- package/apps/web/src/http.ts +31 -4
- package/apps/web/src/info.ts +3 -3
- package/apps/web/src/routes.ts +2 -1
- package/apps/web/src/server.ts +1 -4
- package/dist/doctor.mjs +72715 -697
- package/dist/install-page/analytics.js +97 -0
- package/dist/install-page/index.html +94 -112
- package/dist/install-page/landing.js +54 -0
- package/dist/install-page/og.png +0 -0
- package/dist/install-page/robots.txt +4 -0
- package/dist/install-page/sitemap.xml +21 -0
- package/dist/install-page/start.html +26 -46
- package/dist/install-page/style.css +105 -37
- package/dist/mcp.mjs +74044 -2062
- package/dist/migrate.mjs +110 -90
- package/dist/pdf.worker.min.mjs +28 -0
- package/dist/public/app.js +34 -2
- package/dist/public/index.html +10 -2
- package/dist/public/lib.js +134 -22
- package/dist/public/pages/applications.js +2 -9
- package/dist/public/pages/documents.js +73 -52
- package/dist/public/pages/home.js +16 -18
- package/dist/public/pages/interview.js +9 -12
- package/dist/public/pages/jobs.js +40 -41
- package/dist/public/pages/profile.js +69 -89
- package/dist/public/pages/settings.js +15 -10
- package/dist/public/styles.css +63 -4
- package/dist/web.mjs +39740 -326
- package/package.json +6 -3
- package/packages/db/src/paths.ts +11 -0
- package/packages/db/src/repositories.ts +18 -1
- package/packages/mcp-tools/src/bridge.ts +55 -32
- package/packages/mcp-tools/src/index.ts +9 -0
- package/packages/mcp-tools/src/tools.ts +198 -75
- package/packages/mcp-tools/src/update.ts +330 -0
- package/packages/parsers/src/documents.ts +533 -0
- package/packages/parsers/src/index.ts +8 -5
- package/packages/prompts/src/onboarding.ts +2 -2
- package/packages/shared/src/build-info.ts +22 -0
- package/packages/shared/src/index.ts +1 -0
- package/packages/shared/src/schemas.ts +138 -83
- package/packages/workflows/src/definitions.ts +4 -2
- package/scripts/build-demo.mjs +130 -0
- package/scripts/build-dist.mjs +26 -1
- package/scripts/doctor.ts +16 -3
- package/scripts/gen-og.mjs +1 -1
- package/scripts/test.ts +25 -0
- package/scripts/ui-smoke.ts +32 -4
- package/docs/ARCHITECTURE.md +0 -208
- package/docs/CHANGES_V1.md +0 -103
- package/docs/DATA_MODEL.md +0 -460
- package/docs/DECISIONS.md +0 -277
- package/docs/DEMO.md +0 -242
- package/docs/MCP_TOOLS.md +0 -252
- package/docs/ROADMAP.md +0 -134
- package/docs/RUNTIME_DISTRIBUTION_RESEARCH.md +0 -135
- package/docs/START_WORKFLOW.md +0 -131
- package/docs/SUPPORTED_AI_APPS.md +0 -112
- package/docs/TODO.md +0 -57
- package/docs/UX_NOTES.md +0 -247
- package/docs/WORKFLOWS.md +0 -200
|
@@ -2,16 +2,9 @@
|
|
|
2
2
|
// (no horizontal scroll). Change a status inline to move an item between groups.
|
|
3
3
|
import {
|
|
4
4
|
el, get, put, navigate, EmptyState, Btn, ListRow,
|
|
5
|
-
Select, scoreClass, mount, toast, toastOk, toastError, meta,
|
|
5
|
+
Select, scoreClass, statusColor, mount, toast, toastOk, toastError, meta,
|
|
6
6
|
} from '/lib.js';
|
|
7
7
|
|
|
8
|
-
// status code → theme colour (matches the badge--{status} hues)
|
|
9
|
-
const STATUS_COLOR = {
|
|
10
|
-
draft: 'var(--slate)', planned: 'var(--blue)', applied: 'var(--violet)',
|
|
11
|
-
document_passed: 'var(--teal)', interview: 'var(--amber)',
|
|
12
|
-
final_passed: 'var(--green)', on_hold: 'var(--slate)', rejected: 'var(--red)',
|
|
13
|
-
};
|
|
14
|
-
|
|
15
8
|
// deadline(YYYY-MM-DD) → "마감 D-7" / "마감 D-day" / "마감 지남" (null if absent/invalid)
|
|
16
9
|
function ddayLabel(deadline) {
|
|
17
10
|
if (!deadline) return null;
|
|
@@ -63,7 +56,7 @@ export async function render(ctx) {
|
|
|
63
56
|
function StatusGroup(code, apps) {
|
|
64
57
|
return el('div', {},
|
|
65
58
|
el('div', { class: 'app-group__head' },
|
|
66
|
-
el('span', { class: 'app-group__dot', style: { background:
|
|
59
|
+
el('span', { class: 'app-group__dot', style: { background: statusColor(code) } }),
|
|
67
60
|
el('span', { class: 'app-group__title' }, labelByCode[code] || code),
|
|
68
61
|
el('span', { class: 'app-group__count tnum' }, `${apps.length}건`)),
|
|
69
62
|
el('div', {}, ...apps.map(AppRow)));
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Documents — cover letters (with version history) and résumés/other documents.
|
|
2
2
|
// XSS-safe: all DB/user text rendered via el() textContent / .doc-preview (never innerHTML).
|
|
3
3
|
import {
|
|
4
|
-
el, get, post, put, del, Card, Badge, Btn, IconBtn, EmptyState,
|
|
4
|
+
el, get, post, put, del, Card, Badge, Btn, IconBtn, SubmitBtn, EmptyState,
|
|
5
5
|
Field, Input, Textarea, Select, openModal, confirmDialog,
|
|
6
6
|
toastOk, toastError, copyText, downloadUrl, fmtRelative, mount, meta,
|
|
7
7
|
} from '/lib.js';
|
|
@@ -9,7 +9,10 @@ import {
|
|
|
9
9
|
const SOURCE_LABELS = { manual: '직접 입력', upload: '파일 업로드', ai: 'AI 생성', edit: '직접 수정' };
|
|
10
10
|
const sourceLabel = (s) => SOURCE_LABELS[s] || s || '';
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
// Module-scoped on purpose: remember the last-viewed tab ('cover' | 'docs') across
|
|
13
|
+
// navigations so returning to Documents reopens where the user left off. The search
|
|
14
|
+
// query is intentionally NOT persisted (it resets per visit).
|
|
15
|
+
let tab = 'cover';
|
|
13
16
|
|
|
14
17
|
export async function render(ctx) {
|
|
15
18
|
const root = el('div', { class: 'stack-4' });
|
|
@@ -20,6 +23,7 @@ export async function render(ctx) {
|
|
|
20
23
|
root.append(el('div', { class: 'tabs' }, tabCover, tabDocs), panel);
|
|
21
24
|
|
|
22
25
|
let query = '';
|
|
26
|
+
let searchInput = null;
|
|
23
27
|
function selectTab(next) {
|
|
24
28
|
tab = next;
|
|
25
29
|
query = '';
|
|
@@ -40,9 +44,12 @@ export async function render(ctx) {
|
|
|
40
44
|
if (!hide) shown += 1;
|
|
41
45
|
}
|
|
42
46
|
const existing = panel.querySelector('.search-empty');
|
|
47
|
+
if (existing) existing.remove();
|
|
43
48
|
if (q && cards.length && shown === 0) {
|
|
44
|
-
|
|
45
|
-
|
|
49
|
+
panel.append(el('div', { class: 'search-empty empty' },
|
|
50
|
+
el('p', { class: 'muted', style: { margin: '0 0 12px' } }, `‘${query.trim()}’에 맞는 항목이 없어요.`),
|
|
51
|
+
Btn('필터 초기화', { sm: true, variant: 'ghost', onClick: () => { query = ''; if (searchInput) searchInput.value = ''; applyFilter(); } })));
|
|
52
|
+
}
|
|
46
53
|
}
|
|
47
54
|
|
|
48
55
|
async function reload() {
|
|
@@ -67,6 +74,7 @@ export async function render(ctx) {
|
|
|
67
74
|
function setActions() {
|
|
68
75
|
const search = Input({ type: 'search', placeholder: tab === 'cover' ? '자기소개서 검색' : '문서 검색', value: query, attrs: { 'aria-label': '검색' } });
|
|
69
76
|
search.classList.add('input--inline');
|
|
77
|
+
searchInput = search;
|
|
70
78
|
search.addEventListener('input', () => { query = search.value; applyFilter(); });
|
|
71
79
|
const createBtn = tab === 'cover'
|
|
72
80
|
? Btn('새 자기소개서', { icon: 'plus', variant: 'primary', onClick: () => openCoverCreate(reload) })
|
|
@@ -101,7 +109,7 @@ async function CoverLettersTab(reload) {
|
|
|
101
109
|
function CoverRow(cl, reload) {
|
|
102
110
|
const metaRow = el('div', { class: 'flex gap-2 wrap', style: { marginTop: '4px' } },
|
|
103
111
|
el('span', { class: 'chip' }, `버전 ${cl.version_count}개`),
|
|
104
|
-
cl.is_primary ? Badge('
|
|
112
|
+
cl.is_primary ? Badge('accent', '대표') : null,
|
|
105
113
|
);
|
|
106
114
|
|
|
107
115
|
const actions = el('div', { class: 'flex gap-2 wrap' },
|
|
@@ -129,7 +137,7 @@ function CoverRow(cl, reload) {
|
|
|
129
137
|
async function setPrimaryCover(id, reload) {
|
|
130
138
|
try {
|
|
131
139
|
await put(`/api/cover-letters/${id}/primary`, {});
|
|
132
|
-
toastOk('대표 자기소개서로
|
|
140
|
+
toastOk('대표 자기소개서로 지정했어요.');
|
|
133
141
|
await reload();
|
|
134
142
|
} catch (err) { toastError(err); }
|
|
135
143
|
}
|
|
@@ -143,45 +151,56 @@ async function removeCover(cl, reload) {
|
|
|
143
151
|
if (!ok) return;
|
|
144
152
|
try {
|
|
145
153
|
await del(`/api/cover-letters/${cl.id}`);
|
|
146
|
-
toastOk('자기소개서를
|
|
154
|
+
toastOk('자기소개서를 삭제했어요.');
|
|
147
155
|
await reload();
|
|
148
156
|
} catch (err) { toastError(err); }
|
|
149
157
|
}
|
|
150
158
|
|
|
151
|
-
function openCoverCreate(reload) {
|
|
159
|
+
async function openCoverCreate(reload) {
|
|
160
|
+
// Let the user PICK the job to link instead of typing an internal ID they've never seen.
|
|
161
|
+
let jobs = [];
|
|
162
|
+
try { ({ jobs } = await get('/api/jobs')); } catch { /* dropdown falls back to "연결 안 함" */ }
|
|
152
163
|
const title = Input({ placeholder: '예: 백엔드 개발자 자기소개서', attrs: { maxlength: '200' } });
|
|
153
164
|
const content = Textarea({ placeholder: '내용을 붙여넣으세요. 비워 두면 빈 자기소개서가 만들어집니다.', style: { minHeight: '200px' } });
|
|
154
|
-
const
|
|
165
|
+
const jobPick = Select([
|
|
166
|
+
{ value: '', label: '연결 안 함', selected: true },
|
|
167
|
+
...jobs.map((j) => ({ value: j.id, label: `${j.company} · ${j.position}` })),
|
|
168
|
+
]);
|
|
155
169
|
|
|
156
170
|
openModal({
|
|
157
171
|
title: '새 자기소개서',
|
|
158
172
|
size: 'lg',
|
|
159
173
|
body: el('div', { class: 'stack-3' },
|
|
160
174
|
Field('제목 (필수)', title),
|
|
161
|
-
Field('내용 (선택)', content, '내용을 입력하면
|
|
162
|
-
Field('공고
|
|
175
|
+
Field('내용 (선택)', content, '내용을 입력하면 첫 버전으로 저장됩니다.'),
|
|
176
|
+
Field('연결할 공고 (선택)', jobPick,
|
|
177
|
+
jobs.length ? '특정 공고에 연결하면 그 공고 화면에서 함께 보여요.' : '저장된 공고가 아직 없어요.'),
|
|
163
178
|
),
|
|
164
179
|
footer: (close) => [
|
|
165
180
|
Btn('취소', { onClick: close }),
|
|
166
|
-
|
|
181
|
+
SubmitBtn('자기소개서 저장', async () => {
|
|
167
182
|
const t = title.value.trim();
|
|
168
|
-
if (!t) {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
} catch (err) { toastError(err); }
|
|
178
|
-
} }),
|
|
183
|
+
if (!t) { title.focus(); throw new Error('제목을 입력해 주세요.'); }
|
|
184
|
+
const body = { title: t, source: 'manual' };
|
|
185
|
+
if (content.value.trim()) body.content = content.value;
|
|
186
|
+
if (jobPick.value) body.job_id = jobPick.value;
|
|
187
|
+
await post('/api/cover-letters', body);
|
|
188
|
+
toastOk('자기소개서를 추가했어요.');
|
|
189
|
+
close();
|
|
190
|
+
await reload();
|
|
191
|
+
}),
|
|
179
192
|
],
|
|
180
193
|
});
|
|
181
194
|
}
|
|
182
195
|
|
|
183
196
|
async function openCoverDetail(id, reload) {
|
|
184
|
-
let data
|
|
197
|
+
let data;
|
|
198
|
+
try {
|
|
199
|
+
data = (await get(`/api/cover-letters/${id}`)).cover_letter;
|
|
200
|
+
} catch (err) {
|
|
201
|
+
toastError(err);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
185
204
|
|
|
186
205
|
openModal({
|
|
187
206
|
title: data.title,
|
|
@@ -210,7 +229,7 @@ function CoverDetailBody(cl, { refresh }) {
|
|
|
210
229
|
wrap.append(el('div', { class: 'flex between wrap gap-2' },
|
|
211
230
|
el('div', { class: 'flex gap-2 wrap' },
|
|
212
231
|
el('span', { class: 'chip' }, `버전 ${cl.version_count}개`),
|
|
213
|
-
cl.is_primary ? Badge('
|
|
232
|
+
cl.is_primary ? Badge('accent', '대표') : null,
|
|
214
233
|
),
|
|
215
234
|
el('div', { class: 'flex gap-2 wrap' },
|
|
216
235
|
Btn('MD 내보내기', { sm: true, variant: 'ghost', icon: 'download', onClick: () => downloadUrl(`/api/export/cover-letter/${cl.id}?format=md`) }),
|
|
@@ -277,7 +296,7 @@ function VersionItem(cl, v, { isCurrent, setPreview, refresh }) {
|
|
|
277
296
|
async function makeCurrent(id, versionId, refresh) {
|
|
278
297
|
try {
|
|
279
298
|
await put(`/api/cover-letters/${id}/current-version`, { version_id: versionId });
|
|
280
|
-
toastOk('현재 버전을
|
|
299
|
+
toastOk('현재 버전을 변경했어요.');
|
|
281
300
|
await refresh();
|
|
282
301
|
} catch (err) { toastError(err); }
|
|
283
302
|
}
|
|
@@ -292,16 +311,14 @@ function EditNewVersion(cl, refresh) {
|
|
|
292
311
|
Field('내용', content),
|
|
293
312
|
Field('변경 메모 (선택)', note),
|
|
294
313
|
el('div', { class: 'flex gap-2' },
|
|
295
|
-
|
|
296
|
-
if (!content.value.trim()) {
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
} catch (err) { toastError(err); }
|
|
304
|
-
} }),
|
|
314
|
+
SubmitBtn('새 버전으로 저장', async () => {
|
|
315
|
+
if (!content.value.trim()) { content.focus(); throw new Error('내용을 입력해 주세요.'); }
|
|
316
|
+
await post(`/api/cover-letters/${cl.id}/versions`, {
|
|
317
|
+
content: content.value, note: note.value.trim() || undefined, source: 'edit',
|
|
318
|
+
});
|
|
319
|
+
toastOk('새 버전을 저장했어요.');
|
|
320
|
+
await refresh();
|
|
321
|
+
}, { icon: 'check' }),
|
|
305
322
|
Btn('취소', { variant: 'ghost', onClick: () => { editor.style.display = 'none'; toggle.style.display = ''; } }),
|
|
306
323
|
),
|
|
307
324
|
);
|
|
@@ -344,7 +361,7 @@ function DocRow(doc, kindLabels, m, reload) {
|
|
|
344
361
|
el('div', { class: 'flex gap-2 wrap center' },
|
|
345
362
|
el('span', { class: 'strong' }, doc.title),
|
|
346
363
|
el('span', { class: 'chip' }, kindLabels[doc.kind] || doc.kind),
|
|
347
|
-
doc.is_primary ? Badge('
|
|
364
|
+
doc.is_primary ? Badge('accent', '대표') : null,
|
|
348
365
|
),
|
|
349
366
|
el('span', { class: 'muted text-sm' }, fmtRelative(doc.updated_at)),
|
|
350
367
|
);
|
|
@@ -363,7 +380,13 @@ function DocRow(doc, kindLabels, m, reload) {
|
|
|
363
380
|
}
|
|
364
381
|
|
|
365
382
|
async function openDocDetail(id, kindLabels, m, reload) {
|
|
366
|
-
|
|
383
|
+
let doc;
|
|
384
|
+
try {
|
|
385
|
+
doc = (await get(`/api/documents/${id}`)).document;
|
|
386
|
+
} catch (err) {
|
|
387
|
+
toastError(err);
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
367
390
|
|
|
368
391
|
openModal({
|
|
369
392
|
title: doc.title,
|
|
@@ -371,7 +394,7 @@ async function openDocDetail(id, kindLabels, m, reload) {
|
|
|
371
394
|
body: el('div', { class: 'stack-3' },
|
|
372
395
|
el('div', { class: 'flex gap-2 wrap center' },
|
|
373
396
|
el('span', { class: 'chip' }, kindLabels[doc.kind] || doc.kind),
|
|
374
|
-
doc.is_primary ? Badge('
|
|
397
|
+
doc.is_primary ? Badge('accent', '대표') : null,
|
|
375
398
|
el('span', { class: 'chip' }, sourceLabel(doc.source)),
|
|
376
399
|
el('span', { class: 'muted text-sm' }, `수정 ${fmtRelative(doc.updated_at)}`),
|
|
377
400
|
),
|
|
@@ -383,7 +406,7 @@ async function openDocDetail(id, kindLabels, m, reload) {
|
|
|
383
406
|
Btn('삭제', { variant: 'danger', icon: 'trash', onClick: async () => {
|
|
384
407
|
const ok = await confirmDialog({ title: '문서 삭제', message: `"${doc.title}"을(를) 삭제할까요?`, confirmLabel: '삭제', danger: true });
|
|
385
408
|
if (!ok) return;
|
|
386
|
-
try { await del(`/api/documents/${doc.id}`); toastOk('문서를
|
|
409
|
+
try { await del(`/api/documents/${doc.id}`); toastOk('문서를 삭제했어요.'); close(); await reload(); }
|
|
387
410
|
catch (err) { toastError(err); }
|
|
388
411
|
} }),
|
|
389
412
|
Btn('수정', { variant: 'primary', icon: 'edit', onClick: () => { close(); openDocEdit(doc, m, reload); } }),
|
|
@@ -392,7 +415,7 @@ async function openDocDetail(id, kindLabels, m, reload) {
|
|
|
392
415
|
}
|
|
393
416
|
|
|
394
417
|
function openDocCreate(reload) {
|
|
395
|
-
meta().then((m) => docForm(null, m, reload));
|
|
418
|
+
meta().then((m) => docForm(null, m, reload)).catch(toastError);
|
|
396
419
|
}
|
|
397
420
|
|
|
398
421
|
function openDocEdit(doc, m, reload) {
|
|
@@ -418,19 +441,17 @@ function docForm(doc, m, reload) {
|
|
|
418
441
|
),
|
|
419
442
|
footer: (close) => [
|
|
420
443
|
Btn('취소', { onClick: close }),
|
|
421
|
-
|
|
444
|
+
SubmitBtn('문서 저장', async () => {
|
|
422
445
|
const t = title.value.trim();
|
|
423
|
-
if (!t) {
|
|
424
|
-
if (!content.value.trim()) {
|
|
446
|
+
if (!t) { title.focus(); throw new Error('제목을 입력해 주세요.'); }
|
|
447
|
+
if (!content.value.trim()) { content.focus(); throw new Error('내용을 입력해 주세요.'); }
|
|
425
448
|
const body = { kind: kind.value, title: t, content: content.value, is_primary: primary.checked };
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
} catch (err) { toastError(err); }
|
|
433
|
-
} }),
|
|
449
|
+
if (doc) await put(`/api/documents/${doc.id}`, body);
|
|
450
|
+
else await post('/api/documents', { ...body, source: 'manual' });
|
|
451
|
+
toastOk(doc ? '문서를 수정했어요.' : '문서를 추가했어요.');
|
|
452
|
+
close();
|
|
453
|
+
await reload();
|
|
454
|
+
}),
|
|
434
455
|
],
|
|
435
456
|
});
|
|
436
457
|
}
|
|
@@ -4,17 +4,10 @@
|
|
|
4
4
|
// the 시작하기 card. Every empty state is ONE screen-specific sentence; never repeat
|
|
5
5
|
// the same "AI에게 보내면 저장됩니다" instruction across surfaces.
|
|
6
6
|
import {
|
|
7
|
-
el, get, icon, navigate, Card, Badge, Btn,
|
|
8
|
-
Stat, PageHead, fmtDate, fmtRelative, scoreClass, mount,
|
|
7
|
+
el, get, icon, navigate, Card, Badge, Btn, ListRow, CheckRow,
|
|
8
|
+
Stat, PageHead, fmtDate, fmtRelative, scoreClass, STATUS_COLOR, statusColor, mount,
|
|
9
9
|
} from '/lib.js';
|
|
10
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
11
|
const PIPE_ORDER = ['draft', 'planned', 'applied', 'document_passed', 'interview', 'final_passed', 'on_hold', 'rejected'];
|
|
19
12
|
|
|
20
13
|
// activity type → icon + theme hue (soft tinted chip + coloured stroke)
|
|
@@ -66,7 +59,7 @@ function Greeting(name, s, firstRun) {
|
|
|
66
59
|
let focus;
|
|
67
60
|
if (firstRun) focus = '시작해 볼까요 — 먼저 기본 정보를 채워 주세요.';
|
|
68
61
|
else if (s.interview_todo.length) focus = `면접 준비가 필요한 지원 ${s.interview_todo.length}건이 있어요.`;
|
|
69
|
-
else if (s.in_progress.length) focus = `진행 중인 지원 ${s.in_progress.length}
|
|
62
|
+
else if (s.in_progress.length) focus = `진행 중인 지원 ${s.in_progress.length}건이 있어요.`;
|
|
70
63
|
else if (s.counts.jobs) focus = `저장된 공고 ${s.counts.jobs}건 · 새 적합도 분석을 받아보세요.`;
|
|
71
64
|
else focus = '오늘도 한 걸음 나아가 볼까요.';
|
|
72
65
|
return PageHead({ title: name ? `${name}님, 안녕하세요` : '환영합니다', desc: focus });
|
|
@@ -84,8 +77,17 @@ function GettingStarted(o) {
|
|
|
84
77
|
title: '시작하기',
|
|
85
78
|
sub: `프로필 완성도 ${o.profile_completeness}%`,
|
|
86
79
|
body: [
|
|
80
|
+
// The one place (per DESIGN_GUIDE rule 4) that teaches the core mental model:
|
|
81
|
+
// CareerMate stores; your own AI does the thinking. Without this, first-run users
|
|
82
|
+
// read the empty dashboard as a manual CRUD form and never find the real workflow.
|
|
83
|
+
el('div', { class: 'callout mb-3' },
|
|
84
|
+
icon('info'),
|
|
85
|
+
el('div', {},
|
|
86
|
+
el('div', { class: 'callout__title' }, '두뇌는 당신의 AI, 보관은 CareerMate'),
|
|
87
|
+
el('div', { class: 'callout__body' },
|
|
88
|
+
'공고 분석·자기소개서·면접 준비는 평소 쓰던 AI에게 말로 시키면, 결과가 이 대시보드에 쌓여요.'))),
|
|
87
89
|
el('div', { class: 'progress' }, el('div', { class: 'progress__bar', style: { width: `${o.profile_completeness}%` } })),
|
|
88
|
-
el('p', { class: 'text-secondary text-sm
|
|
90
|
+
el('p', { class: 'text-secondary text-sm mt-3 mb-2' },
|
|
89
91
|
'완성할수록 AI의 적합도 분석과 자기소개서 품질이 좋아져요.'),
|
|
90
92
|
el('div', {}, ...steps.map((it) => CheckRow({ done: it.done, label: it.label, onClick: () => navigate(it.to) }))),
|
|
91
93
|
],
|
|
@@ -94,9 +96,8 @@ function GettingStarted(o) {
|
|
|
94
96
|
|
|
95
97
|
/* ----------------------------------------------- KPI summary tiles (at-a-glance) */
|
|
96
98
|
function StatTile(label, value, iconName, to) {
|
|
97
|
-
const tile = Stat({ label, value, iconName });
|
|
99
|
+
const tile = Stat({ label, value, iconName, onClick: () => navigate(to) });
|
|
98
100
|
tile.classList.add('is-clickable');
|
|
99
|
-
tile.addEventListener('click', () => navigate(to));
|
|
100
101
|
return tile;
|
|
101
102
|
}
|
|
102
103
|
function StatRow(counts) {
|
|
@@ -162,7 +163,7 @@ function Pipeline(breakdown) {
|
|
|
162
163
|
|
|
163
164
|
/* ----------------------------------------------- action lane (what to do next) */
|
|
164
165
|
function statusDot(status) {
|
|
165
|
-
return el('span', { class: 'list-row__dot', style: { background:
|
|
166
|
+
return el('span', { class: 'list-row__dot', style: { background: statusColor(status) } });
|
|
166
167
|
}
|
|
167
168
|
|
|
168
169
|
function ActionLane(interviewTodo, inProgress) {
|
|
@@ -200,10 +201,7 @@ function RecentJobs(jobs) {
|
|
|
200
201
|
if (!jobs.length) {
|
|
201
202
|
return Card({
|
|
202
203
|
title: '최근 공고',
|
|
203
|
-
body:
|
|
204
|
-
iconName: 'briefcase', title: '저장된 공고가 아직 없어요',
|
|
205
|
-
action: Btn('공고 추가', { icon: 'plus', variant: 'ghost', onClick: () => navigate('/jobs') }),
|
|
206
|
-
}),
|
|
204
|
+
body: el('p', { class: 'muted', style: { margin: 0 } }, '저장된 공고가 아직 없어요. 새 공고를 추가해 보세요.'),
|
|
207
205
|
});
|
|
208
206
|
}
|
|
209
207
|
const rows = jobs.map((j) => ListRow({
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Interview — preparation materials for jobs that reached 서류 합격 or beyond.
|
|
2
2
|
import {
|
|
3
|
-
el, get, put, icon, navigate, Card, Badge, Btn, EmptyState, Field, Input,
|
|
4
|
-
Textarea, openModal, closeModal, toastOk,
|
|
3
|
+
el, get, put, icon, navigate, Card, Badge, Btn, SubmitBtn, EmptyState, Field, Input,
|
|
4
|
+
Textarea, openModal, closeModal, toastOk, copyText,
|
|
5
5
|
downloadUrl, mount,
|
|
6
6
|
} from '/lib.js';
|
|
7
7
|
|
|
@@ -200,7 +200,7 @@ export async function render(ctx) {
|
|
|
200
200
|
body: formBody,
|
|
201
201
|
footer: (close) => [
|
|
202
202
|
Btn('취소', { onClick: close }),
|
|
203
|
-
|
|
203
|
+
SubmitBtn('면접 준비 저장', () => save(jobId)),
|
|
204
204
|
],
|
|
205
205
|
});
|
|
206
206
|
|
|
@@ -216,15 +216,12 @@ export async function render(ctx) {
|
|
|
216
216
|
self_introduction: introInput.value,
|
|
217
217
|
notes: notesInput.value,
|
|
218
218
|
};
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
} catch (err) {
|
|
226
|
-
toastError(err);
|
|
227
|
-
}
|
|
219
|
+
// Throws on failure so the SubmitBtn surfaces it inline.
|
|
220
|
+
await put(`/api/jobs/${id}/interview`, payload);
|
|
221
|
+
closeModal();
|
|
222
|
+
toastOk('면접 준비를 저장했어요.');
|
|
223
|
+
await ctx.refreshNav();
|
|
224
|
+
await render(ctx);
|
|
228
225
|
}
|
|
229
226
|
}
|
|
230
227
|
}
|
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
// cover letters, interview prep, and related history, all driven by the AI's
|
|
4
4
|
// saved data with manual fallbacks throughout.
|
|
5
5
|
import {
|
|
6
|
-
el, get, post, put, del, icon, navigate, Card, Badge, Btn,
|
|
6
|
+
el, get, post, put, del, icon, navigate, Card, Badge, Btn, SubmitBtn,
|
|
7
7
|
EmptyState, Chips, Field, Input, Textarea, Select,
|
|
8
8
|
openModal, closeModal, confirmDialog, toastOk, toastError,
|
|
9
|
-
fmtDate, scoreClass, mount, meta,
|
|
9
|
+
fmtDate, scoreClass, mount, meta, linesToArray, csvToArray,
|
|
10
10
|
} from '/lib.js';
|
|
11
11
|
|
|
12
12
|
export async function render(ctx) {
|
|
@@ -40,7 +40,9 @@ async function renderList(ctx) {
|
|
|
40
40
|
const rowData = jobs.map((j) => ({
|
|
41
41
|
tr: el('tr', { class: 'is-clickable', onClick: () => navigate(`/jobs/${j.id}`) },
|
|
42
42
|
el('td', {},
|
|
43
|
-
|
|
43
|
+
// Real link in the lead cell so keyboard/AT users can reach the row (a
|
|
44
|
+
// role=button on <tr> would be invalid ARIA); the whole row stays mouse-clickable.
|
|
45
|
+
el('a', { class: 'cell-link strong', href: `#/jobs/${j.id}` }, j.company),
|
|
44
46
|
el('div', { class: 'muted text-sm' }, j.position)),
|
|
45
47
|
el('td', {},
|
|
46
48
|
el('div', {}, j.location || el('span', { class: 'muted' }, '위치 미정')),
|
|
@@ -61,13 +63,13 @@ async function renderList(ctx) {
|
|
|
61
63
|
wrap.append(Card({
|
|
62
64
|
title: '저장된 공고',
|
|
63
65
|
sub: `${jobs.length}개`,
|
|
64
|
-
body: el('table', { class: 'table' },
|
|
66
|
+
body: el('div', { class: 'table-scroll' }, el('table', { class: 'table table--jobs' },
|
|
65
67
|
el('thead', {}, el('tr', {},
|
|
66
68
|
el('th', {}, '회사 · 직무'),
|
|
67
69
|
el('th', {}, '위치 · 마감일'),
|
|
68
70
|
el('th', { style: { textAlign: 'right' } }, '적합도'),
|
|
69
71
|
el('th', {}, '상태'))),
|
|
70
|
-
tbody),
|
|
72
|
+
tbody)),
|
|
71
73
|
}));
|
|
72
74
|
|
|
73
75
|
mount(ctx.view, wrap);
|
|
@@ -82,13 +84,6 @@ async function renderList(ctx) {
|
|
|
82
84
|
|
|
83
85
|
/* ---------------------------------------------------------- add/edit modal */
|
|
84
86
|
|
|
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
87
|
/**
|
|
93
88
|
* Build a job form (used for both create and edit). `job` is the existing
|
|
94
89
|
* record when editing, or null when creating. `onSaved(result)` receives the
|
|
@@ -114,12 +109,17 @@ function jobForm(ctx, { job, onSaved }) {
|
|
|
114
109
|
});
|
|
115
110
|
|
|
116
111
|
// "붙여넣은 텍스트 정리" — server-side clean + best-effort field extraction.
|
|
112
|
+
// Deterministic text parsing — NOT AI (CareerMate has no LLM). Use a neutral icon and
|
|
113
|
+
// show "정리 중…" so a slow parse of a long posting doesn't look frozen.
|
|
117
114
|
const tidyBtn = Btn('붙여넣은 텍스트 정리', {
|
|
118
|
-
icon: '
|
|
115
|
+
icon: 'edit', sm: true, variant: 'ghost',
|
|
119
116
|
onClick: async () => {
|
|
120
117
|
const raw = description.value.trim();
|
|
121
118
|
if (!raw) { toastError('먼저 공고 원문을 붙여넣어 주세요.'); return; }
|
|
119
|
+
const labelEl = tidyBtn.querySelector('span');
|
|
120
|
+
const origLabel = labelEl ? labelEl.textContent : '';
|
|
122
121
|
tidyBtn.disabled = true;
|
|
122
|
+
if (labelEl) labelEl.textContent = '정리 중…';
|
|
123
123
|
try {
|
|
124
124
|
const r = await post('/api/parse/job', { raw });
|
|
125
125
|
if (r.text) description.value = r.text;
|
|
@@ -133,11 +133,11 @@ function jobForm(ctx, { job, onSaved }) {
|
|
|
133
133
|
}
|
|
134
134
|
toastOk('붙여넣은 텍스트를 정리하고 일부 항목을 채웠어요.');
|
|
135
135
|
} catch (e) { toastError(e); }
|
|
136
|
-
finally { tidyBtn.disabled = false; }
|
|
136
|
+
finally { tidyBtn.disabled = false; if (labelEl) labelEl.textContent = origLabel; }
|
|
137
137
|
},
|
|
138
138
|
});
|
|
139
139
|
|
|
140
|
-
const form = el('form', { class: 'stack-3'
|
|
140
|
+
const form = el('form', { class: 'stack-3' },
|
|
141
141
|
el('div', { class: 'grid grid--2' },
|
|
142
142
|
Field('회사 *', company),
|
|
143
143
|
Field('직무 *', position)),
|
|
@@ -155,8 +155,8 @@ function jobForm(ctx, { job, onSaved }) {
|
|
|
155
155
|
Field('자격요건 · 우대사항', requirements, '한 줄에 하나씩'),
|
|
156
156
|
);
|
|
157
157
|
|
|
158
|
-
|
|
159
|
-
|
|
158
|
+
// Throws on validation/API failure so the SubmitBtn shows it inline.
|
|
159
|
+
async function submit() {
|
|
160
160
|
const body = {
|
|
161
161
|
company: company.value.trim(),
|
|
162
162
|
position: position.value.trim(),
|
|
@@ -169,21 +169,20 @@ function jobForm(ctx, { job, onSaved }) {
|
|
|
169
169
|
keywords: csvToArray(keywords.value),
|
|
170
170
|
requirements: linesToArray(requirements.value),
|
|
171
171
|
};
|
|
172
|
-
if (!body.company || !body.position)
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
: await post('/api/jobs', body);
|
|
180
|
-
toastOk(job ? '공고를 수정했습니다.' : '공고를 저장했습니다.');
|
|
181
|
-
closeModal();
|
|
182
|
-
await onSaved(res);
|
|
183
|
-
} catch (err) { toastError(err); }
|
|
172
|
+
if (!body.company || !body.position) throw new Error('회사와 직무는 필수입니다.');
|
|
173
|
+
const res = job
|
|
174
|
+
? await put(`/api/jobs/${job.id}`, body)
|
|
175
|
+
: await post('/api/jobs', body);
|
|
176
|
+
toastOk(job ? '공고를 수정했어요.' : '공고를 저장했어요.');
|
|
177
|
+
closeModal();
|
|
178
|
+
await onSaved(res);
|
|
184
179
|
}
|
|
185
180
|
|
|
186
|
-
|
|
181
|
+
// Route the form's Enter-to-submit through the footer SubmitBtn so it shares the
|
|
182
|
+
// same in-flight disable + inline error handling.
|
|
183
|
+
const bindSubmit = (btn) => form.addEventListener('submit', (e) => { e.preventDefault(); btn.click(); });
|
|
184
|
+
|
|
185
|
+
return { form, submit, bindSubmit };
|
|
187
186
|
}
|
|
188
187
|
|
|
189
188
|
function openJobModal(ctx) {
|
|
@@ -194,13 +193,15 @@ function openJobModal(ctx) {
|
|
|
194
193
|
navigate(`/jobs/${res.job.id}`);
|
|
195
194
|
},
|
|
196
195
|
});
|
|
196
|
+
const saveBtn = SubmitBtn('공고 저장', built.submit);
|
|
197
|
+
built.bindSubmit(saveBtn);
|
|
197
198
|
openModal({
|
|
198
199
|
title: '공고 추가',
|
|
199
200
|
size: 'lg',
|
|
200
201
|
body: built.form,
|
|
201
202
|
footer: (close) => [
|
|
202
203
|
Btn('취소', { onClick: close }),
|
|
203
|
-
|
|
204
|
+
saveBtn,
|
|
204
205
|
],
|
|
205
206
|
});
|
|
206
207
|
}
|
|
@@ -210,13 +211,15 @@ function openEditModal(ctx, job, remount) {
|
|
|
210
211
|
job,
|
|
211
212
|
onSaved: async () => { await ctx.refreshNav(); await remount(); },
|
|
212
213
|
});
|
|
214
|
+
const saveBtn = SubmitBtn('공고 저장', built.submit);
|
|
215
|
+
built.bindSubmit(saveBtn);
|
|
213
216
|
openModal({
|
|
214
217
|
title: '공고 수정',
|
|
215
218
|
size: 'lg',
|
|
216
219
|
body: built.form,
|
|
217
220
|
footer: (close) => [
|
|
218
221
|
Btn('취소', { onClick: close }),
|
|
219
|
-
|
|
222
|
+
saveBtn,
|
|
220
223
|
],
|
|
221
224
|
});
|
|
222
225
|
}
|
|
@@ -255,7 +258,7 @@ async function renderDetail(ctx, jobId) {
|
|
|
255
258
|
if (status === job.status) return;
|
|
256
259
|
try {
|
|
257
260
|
const res = await put(`/api/applications/${jobId}/status`, { status });
|
|
258
|
-
toastOk('상태를
|
|
261
|
+
toastOk('상태를 변경했어요.');
|
|
259
262
|
if (res && res.hint) toastOk(res.hint);
|
|
260
263
|
await ctx.refreshNav();
|
|
261
264
|
await remount();
|
|
@@ -277,7 +280,7 @@ async function renderDetail(ctx, jobId) {
|
|
|
277
280
|
if (!ok) return;
|
|
278
281
|
try {
|
|
279
282
|
await del(`/api/jobs/${jobId}`);
|
|
280
|
-
toastOk('공고를
|
|
283
|
+
toastOk('공고를 삭제했어요.');
|
|
281
284
|
await ctx.refreshNav();
|
|
282
285
|
navigate('/jobs');
|
|
283
286
|
} catch (err) { toastError(err); }
|
|
@@ -362,11 +365,7 @@ function FitCard(fit) {
|
|
|
362
365
|
if (!fit) {
|
|
363
366
|
return Card({
|
|
364
367
|
title: '적합도 분석',
|
|
365
|
-
body:
|
|
366
|
-
iconName: 'target',
|
|
367
|
-
title: '적합도 분석 전이에요',
|
|
368
|
-
body: '이 공고와 내 프로필을 비교한 분석 결과가 여기에 표시됩니다.',
|
|
369
|
-
}),
|
|
368
|
+
body: el('p', { class: 'muted', style: { margin: 0 } }, '아직 분석 전이에요. 이 공고와 내 프로필을 비교한 결과가 여기에 표시됩니다.'),
|
|
370
369
|
});
|
|
371
370
|
}
|
|
372
371
|
|
|
@@ -436,7 +435,7 @@ function CoverLettersCard(coverLetters) {
|
|
|
436
435
|
...list.map((cl) => el('div', {
|
|
437
436
|
class: 'flex between center is-clickable',
|
|
438
437
|
style: { padding: '8px 0', cursor: 'pointer' },
|
|
439
|
-
|
|
438
|
+
activate: () => navigate('/documents'),
|
|
440
439
|
},
|
|
441
440
|
el('div', { class: 'flex gap-2 center' },
|
|
442
441
|
icon('file', 'muted'),
|
|
@@ -484,7 +483,7 @@ function RelatedCard(related) {
|
|
|
484
483
|
...list.map((r) => el('div', {
|
|
485
484
|
class: 'flex between center is-clickable',
|
|
486
485
|
style: { padding: '8px 0', cursor: 'pointer' },
|
|
487
|
-
|
|
486
|
+
activate: () => navigate(`/jobs/${r.id}`),
|
|
488
487
|
},
|
|
489
488
|
el('div', {},
|
|
490
489
|
el('div', { class: 'strong' }, r.position),
|