careermate 0.1.0 → 0.1.2
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 +22 -17
- package/THIRD_PARTY_NOTICES.md +15 -0
- package/apps/mcp/src/index.ts +4 -3
- package/apps/web/DESIGN_GUIDE.md +2 -2
- 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/icon-180.png +0 -0
- package/apps/web/public/index.html +14 -3
- package/apps/web/public/lib.js +135 -23
- package/apps/web/public/pages/applications.js +18 -9
- package/apps/web/public/pages/documents.js +66 -50
- package/apps/web/public/pages/home.js +12 -11
- package/apps/web/public/pages/interview.js +9 -12
- package/apps/web/public/pages/jobs.js +39 -36
- package/apps/web/public/pages/profile.js +66 -86
- package/apps/web/public/pages/settings.js +21 -14
- package/apps/web/public/styles.css +105 -38
- package/apps/web/src/http.ts +31 -4
- package/apps/web/src/info.ts +9 -22
- package/apps/web/src/routes.ts +19 -14
- package/apps/web/src/server.ts +34 -29
- package/apps/web/src/settings.ts +35 -17
- package/bin/careermate.mjs +8 -5
- package/dist/doctor.mjs +78908 -0
- package/dist/install-page/.omc/state/agent-replay-55a58c0d-bb6e-43ea-8a05-4b6c93b80b34.jsonl +12 -0
- package/dist/install-page/.omc/state/idle-notif-cooldown.json +3 -0
- package/dist/install-page/.omc/state/sessions/55a58c0d-bb6e-43ea-8a05-4b6c93b80b34/mission-state.json +147 -0
- package/dist/install-page/.omc/state/sessions/55a58c0d-bb6e-43ea-8a05-4b6c93b80b34/pre-tool-advisory-throttle.json +22 -0
- package/dist/install-page/.omc/state/sessions/55a58c0d-bb6e-43ea-8a05-4b6c93b80b34/subagent-tracking-state.json +44 -0
- package/dist/install-page/favicon.svg +1 -0
- package/dist/install-page/icon-180.png +0 -0
- package/dist/install-page/icon-512.png +0 -0
- package/dist/install-page/index.html +328 -225
- package/dist/install-page/landing.js +54 -0
- package/dist/install-page/llms-install.txt +202 -0
- package/dist/install-page/og.png +0 -0
- package/dist/install-page/shots/applications-dark.png +0 -0
- package/dist/install-page/shots/applications-light.png +0 -0
- package/dist/install-page/shots/documents-dark.png +0 -0
- package/dist/install-page/shots/documents-light.png +0 -0
- package/dist/install-page/shots/home-dark.png +0 -0
- package/dist/install-page/shots/home-light.png +0 -0
- package/dist/install-page/shots/interview-dark.png +0 -0
- package/dist/install-page/shots/interview-light.png +0 -0
- package/dist/install-page/shots/jobs-dark.png +0 -0
- package/dist/install-page/shots/jobs-light.png +0 -0
- package/dist/install-page/shots/settings-dark.png +0 -0
- package/dist/install-page/shots/settings-light.png +0 -0
- package/dist/install-page/start.html +323 -0
- package/dist/install-page/style.css +128 -4
- package/dist/mcp.mjs +74225 -2007
- package/dist/{mcpb-stage/dist/web.mjs → migrate.mjs} +142 -2660
- package/dist/pdf.worker.min.mjs +28 -0
- package/dist/public/app.js +34 -2
- package/dist/public/icon-180.png +0 -0
- package/dist/public/index.html +14 -3
- package/dist/public/lib.js +135 -23
- package/dist/public/pages/applications.js +18 -9
- package/dist/public/pages/documents.js +66 -50
- package/dist/public/pages/home.js +12 -11
- package/dist/public/pages/interview.js +9 -12
- package/dist/public/pages/jobs.js +39 -36
- package/dist/public/pages/profile.js +66 -86
- package/dist/public/pages/settings.js +21 -14
- package/dist/public/styles.css +105 -38
- package/dist/web.mjs +39950 -369
- package/docs/ARCHITECTURE.md +5 -5
- package/docs/DECISIONS.md +1 -1
- package/docs/DEMO.md +6 -6
- package/docs/MCP_TOOLS.md +68 -9
- package/docs/ROADMAP.md +11 -10
- package/docs/RUNTIME_DISTRIBUTION_RESEARCH.md +135 -0
- package/docs/START_WORKFLOW.md +19 -7
- package/docs/SUPPORTED_AI_APPS.md +85 -33
- package/docs/TODO.md +1 -1
- package/docs/WORKFLOWS.md +8 -6
- package/docs/superpowers/specs/2026-06-16-demo-dashboard-deploy-design.md +64 -0
- package/package.json +8 -3
- package/packages/core/src/services.ts +42 -1
- package/packages/db/src/connection.ts +30 -0
- package/packages/db/src/index.ts +1 -0
- package/packages/db/src/paths.ts +11 -0
- package/packages/db/src/repositories.ts +109 -63
- package/packages/db/src/schema.ts +118 -0
- package/packages/exporters/src/html.ts +9 -12
- 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 +269 -65
- package/packages/mcp-tools/src/update.ts +330 -0
- package/packages/parsers/src/documents.ts +521 -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/enums.ts +39 -0
- package/packages/shared/src/index.ts +1 -0
- package/packages/shared/src/schemas.ts +24 -3
- package/packages/workflows/src/definitions.ts +4 -2
- package/scripts/build-demo.mjs +130 -0
- package/scripts/build-dist.mjs +46 -8
- package/scripts/build-mcpb.mjs +60 -4
- package/scripts/doctor.ts +16 -3
- package/scripts/gen-og.mjs +86 -0
- package/scripts/init.ts +32 -3
- package/scripts/seed.ts +5 -5
- package/scripts/shots-seed.ts +192 -0
- package/scripts/shots.ts +89 -0
- package/tsconfig.json +3 -1
- package/dist/careermate.mcpb +0 -0
- package/dist/mcp-smoke.err +0 -3
- package/dist/mcpb-stage/README.md +0 -219
- package/dist/mcpb-stage/dist/install-page/index.html +0 -434
- package/dist/mcpb-stage/dist/install-page/style.css +0 -407
- package/dist/mcpb-stage/dist/install-page/vercel.json +0 -20
- package/dist/mcpb-stage/dist/mcp.mjs +0 -23704
- package/dist/mcpb-stage/dist/public/app.js +0 -118
- package/dist/mcpb-stage/dist/public/index.html +0 -41
- package/dist/mcpb-stage/dist/public/lib.js +0 -282
- package/dist/mcpb-stage/dist/public/pages/applications.js +0 -98
- package/dist/mcpb-stage/dist/public/pages/documents.js +0 -446
- package/dist/mcpb-stage/dist/public/pages/home.js +0 -263
- package/dist/mcpb-stage/dist/public/pages/interview.js +0 -230
- package/dist/mcpb-stage/dist/public/pages/jobs.js +0 -494
- package/dist/mcpb-stage/dist/public/pages/profile.js +0 -576
- package/dist/mcpb-stage/dist/public/pages/settings.js +0 -233
- package/dist/mcpb-stage/dist/public/styles.css +0 -420
- package/dist/mcpb-stage/manifest.json +0 -40
- package/docs/INSTALL.md +0 -148
- package/docs/INSTALL_AND_USAGE.md +0 -99
- package/install-page/index.html +0 -474
- package/install-page/style.css +0 -391
- package/install-page/vercel.json +0 -20
- /package/dist/{mcpb-stage/dist/public → install-page}/fonts/PretendardVariable.woff2 +0 -0
|
@@ -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' });
|
|
@@ -101,7 +104,7 @@ async function CoverLettersTab(reload) {
|
|
|
101
104
|
function CoverRow(cl, reload) {
|
|
102
105
|
const metaRow = el('div', { class: 'flex gap-2 wrap', style: { marginTop: '4px' } },
|
|
103
106
|
el('span', { class: 'chip' }, `버전 ${cl.version_count}개`),
|
|
104
|
-
cl.is_primary ? Badge('
|
|
107
|
+
cl.is_primary ? Badge('accent', '대표') : null,
|
|
105
108
|
);
|
|
106
109
|
|
|
107
110
|
const actions = el('div', { class: 'flex gap-2 wrap' },
|
|
@@ -129,7 +132,7 @@ function CoverRow(cl, reload) {
|
|
|
129
132
|
async function setPrimaryCover(id, reload) {
|
|
130
133
|
try {
|
|
131
134
|
await put(`/api/cover-letters/${id}/primary`, {});
|
|
132
|
-
toastOk('대표 자기소개서로
|
|
135
|
+
toastOk('대표 자기소개서로 지정했어요.');
|
|
133
136
|
await reload();
|
|
134
137
|
} catch (err) { toastError(err); }
|
|
135
138
|
}
|
|
@@ -143,45 +146,56 @@ async function removeCover(cl, reload) {
|
|
|
143
146
|
if (!ok) return;
|
|
144
147
|
try {
|
|
145
148
|
await del(`/api/cover-letters/${cl.id}`);
|
|
146
|
-
toastOk('자기소개서를
|
|
149
|
+
toastOk('자기소개서를 삭제했어요.');
|
|
147
150
|
await reload();
|
|
148
151
|
} catch (err) { toastError(err); }
|
|
149
152
|
}
|
|
150
153
|
|
|
151
|
-
function openCoverCreate(reload) {
|
|
154
|
+
async function openCoverCreate(reload) {
|
|
155
|
+
// Let the user PICK the job to link instead of typing an internal ID they've never seen.
|
|
156
|
+
let jobs = [];
|
|
157
|
+
try { ({ jobs } = await get('/api/jobs')); } catch { /* dropdown falls back to "연결 안 함" */ }
|
|
152
158
|
const title = Input({ placeholder: '예: 백엔드 개발자 자기소개서', attrs: { maxlength: '200' } });
|
|
153
159
|
const content = Textarea({ placeholder: '내용을 붙여넣으세요. 비워 두면 빈 자기소개서가 만들어집니다.', style: { minHeight: '200px' } });
|
|
154
|
-
const
|
|
160
|
+
const jobPick = Select([
|
|
161
|
+
{ value: '', label: '연결 안 함', selected: true },
|
|
162
|
+
...jobs.map((j) => ({ value: j.id, label: `${j.company} · ${j.position}` })),
|
|
163
|
+
]);
|
|
155
164
|
|
|
156
165
|
openModal({
|
|
157
166
|
title: '새 자기소개서',
|
|
158
167
|
size: 'lg',
|
|
159
168
|
body: el('div', { class: 'stack-3' },
|
|
160
169
|
Field('제목 (필수)', title),
|
|
161
|
-
Field('내용 (선택)', content, '내용을 입력하면
|
|
162
|
-
Field('공고
|
|
170
|
+
Field('내용 (선택)', content, '내용을 입력하면 첫 버전으로 저장됩니다.'),
|
|
171
|
+
Field('연결할 공고 (선택)', jobPick,
|
|
172
|
+
jobs.length ? '특정 공고에 연결하면 그 공고 화면에서 함께 보여요.' : '저장된 공고가 아직 없어요.'),
|
|
163
173
|
),
|
|
164
174
|
footer: (close) => [
|
|
165
175
|
Btn('취소', { onClick: close }),
|
|
166
|
-
|
|
176
|
+
SubmitBtn('자기소개서 저장', async () => {
|
|
167
177
|
const t = title.value.trim();
|
|
168
|
-
if (!t) {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
} catch (err) { toastError(err); }
|
|
178
|
-
} }),
|
|
178
|
+
if (!t) { title.focus(); throw new Error('제목을 입력해 주세요.'); }
|
|
179
|
+
const body = { title: t, source: 'manual' };
|
|
180
|
+
if (content.value.trim()) body.content = content.value;
|
|
181
|
+
if (jobPick.value) body.job_id = jobPick.value;
|
|
182
|
+
await post('/api/cover-letters', body);
|
|
183
|
+
toastOk('자기소개서를 추가했어요.');
|
|
184
|
+
close();
|
|
185
|
+
await reload();
|
|
186
|
+
}),
|
|
179
187
|
],
|
|
180
188
|
});
|
|
181
189
|
}
|
|
182
190
|
|
|
183
191
|
async function openCoverDetail(id, reload) {
|
|
184
|
-
let data
|
|
192
|
+
let data;
|
|
193
|
+
try {
|
|
194
|
+
data = (await get(`/api/cover-letters/${id}`)).cover_letter;
|
|
195
|
+
} catch (err) {
|
|
196
|
+
toastError(err);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
185
199
|
|
|
186
200
|
openModal({
|
|
187
201
|
title: data.title,
|
|
@@ -210,7 +224,7 @@ function CoverDetailBody(cl, { refresh }) {
|
|
|
210
224
|
wrap.append(el('div', { class: 'flex between wrap gap-2' },
|
|
211
225
|
el('div', { class: 'flex gap-2 wrap' },
|
|
212
226
|
el('span', { class: 'chip' }, `버전 ${cl.version_count}개`),
|
|
213
|
-
cl.is_primary ? Badge('
|
|
227
|
+
cl.is_primary ? Badge('accent', '대표') : null,
|
|
214
228
|
),
|
|
215
229
|
el('div', { class: 'flex gap-2 wrap' },
|
|
216
230
|
Btn('MD 내보내기', { sm: true, variant: 'ghost', icon: 'download', onClick: () => downloadUrl(`/api/export/cover-letter/${cl.id}?format=md`) }),
|
|
@@ -277,7 +291,7 @@ function VersionItem(cl, v, { isCurrent, setPreview, refresh }) {
|
|
|
277
291
|
async function makeCurrent(id, versionId, refresh) {
|
|
278
292
|
try {
|
|
279
293
|
await put(`/api/cover-letters/${id}/current-version`, { version_id: versionId });
|
|
280
|
-
toastOk('현재 버전을
|
|
294
|
+
toastOk('현재 버전을 변경했어요.');
|
|
281
295
|
await refresh();
|
|
282
296
|
} catch (err) { toastError(err); }
|
|
283
297
|
}
|
|
@@ -292,16 +306,14 @@ function EditNewVersion(cl, refresh) {
|
|
|
292
306
|
Field('내용', content),
|
|
293
307
|
Field('변경 메모 (선택)', note),
|
|
294
308
|
el('div', { class: 'flex gap-2' },
|
|
295
|
-
|
|
296
|
-
if (!content.value.trim()) {
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
} catch (err) { toastError(err); }
|
|
304
|
-
} }),
|
|
309
|
+
SubmitBtn('새 버전으로 저장', async () => {
|
|
310
|
+
if (!content.value.trim()) { content.focus(); throw new Error('내용을 입력해 주세요.'); }
|
|
311
|
+
await post(`/api/cover-letters/${cl.id}/versions`, {
|
|
312
|
+
content: content.value, note: note.value.trim() || undefined, source: 'edit',
|
|
313
|
+
});
|
|
314
|
+
toastOk('새 버전을 저장했어요.');
|
|
315
|
+
await refresh();
|
|
316
|
+
}, { icon: 'check' }),
|
|
305
317
|
Btn('취소', { variant: 'ghost', onClick: () => { editor.style.display = 'none'; toggle.style.display = ''; } }),
|
|
306
318
|
),
|
|
307
319
|
);
|
|
@@ -344,7 +356,7 @@ function DocRow(doc, kindLabels, m, reload) {
|
|
|
344
356
|
el('div', { class: 'flex gap-2 wrap center' },
|
|
345
357
|
el('span', { class: 'strong' }, doc.title),
|
|
346
358
|
el('span', { class: 'chip' }, kindLabels[doc.kind] || doc.kind),
|
|
347
|
-
doc.is_primary ? Badge('
|
|
359
|
+
doc.is_primary ? Badge('accent', '대표') : null,
|
|
348
360
|
),
|
|
349
361
|
el('span', { class: 'muted text-sm' }, fmtRelative(doc.updated_at)),
|
|
350
362
|
);
|
|
@@ -363,7 +375,13 @@ function DocRow(doc, kindLabels, m, reload) {
|
|
|
363
375
|
}
|
|
364
376
|
|
|
365
377
|
async function openDocDetail(id, kindLabels, m, reload) {
|
|
366
|
-
|
|
378
|
+
let doc;
|
|
379
|
+
try {
|
|
380
|
+
doc = (await get(`/api/documents/${id}`)).document;
|
|
381
|
+
} catch (err) {
|
|
382
|
+
toastError(err);
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
367
385
|
|
|
368
386
|
openModal({
|
|
369
387
|
title: doc.title,
|
|
@@ -371,7 +389,7 @@ async function openDocDetail(id, kindLabels, m, reload) {
|
|
|
371
389
|
body: el('div', { class: 'stack-3' },
|
|
372
390
|
el('div', { class: 'flex gap-2 wrap center' },
|
|
373
391
|
el('span', { class: 'chip' }, kindLabels[doc.kind] || doc.kind),
|
|
374
|
-
doc.is_primary ? Badge('
|
|
392
|
+
doc.is_primary ? Badge('accent', '대표') : null,
|
|
375
393
|
el('span', { class: 'chip' }, sourceLabel(doc.source)),
|
|
376
394
|
el('span', { class: 'muted text-sm' }, `수정 ${fmtRelative(doc.updated_at)}`),
|
|
377
395
|
),
|
|
@@ -383,7 +401,7 @@ async function openDocDetail(id, kindLabels, m, reload) {
|
|
|
383
401
|
Btn('삭제', { variant: 'danger', icon: 'trash', onClick: async () => {
|
|
384
402
|
const ok = await confirmDialog({ title: '문서 삭제', message: `"${doc.title}"을(를) 삭제할까요?`, confirmLabel: '삭제', danger: true });
|
|
385
403
|
if (!ok) return;
|
|
386
|
-
try { await del(`/api/documents/${doc.id}`); toastOk('문서를
|
|
404
|
+
try { await del(`/api/documents/${doc.id}`); toastOk('문서를 삭제했어요.'); close(); await reload(); }
|
|
387
405
|
catch (err) { toastError(err); }
|
|
388
406
|
} }),
|
|
389
407
|
Btn('수정', { variant: 'primary', icon: 'edit', onClick: () => { close(); openDocEdit(doc, m, reload); } }),
|
|
@@ -392,7 +410,7 @@ async function openDocDetail(id, kindLabels, m, reload) {
|
|
|
392
410
|
}
|
|
393
411
|
|
|
394
412
|
function openDocCreate(reload) {
|
|
395
|
-
meta().then((m) => docForm(null, m, reload));
|
|
413
|
+
meta().then((m) => docForm(null, m, reload)).catch(toastError);
|
|
396
414
|
}
|
|
397
415
|
|
|
398
416
|
function openDocEdit(doc, m, reload) {
|
|
@@ -418,19 +436,17 @@ function docForm(doc, m, reload) {
|
|
|
418
436
|
),
|
|
419
437
|
footer: (close) => [
|
|
420
438
|
Btn('취소', { onClick: close }),
|
|
421
|
-
|
|
439
|
+
SubmitBtn('문서 저장', async () => {
|
|
422
440
|
const t = title.value.trim();
|
|
423
|
-
if (!t) {
|
|
424
|
-
if (!content.value.trim()) {
|
|
441
|
+
if (!t) { title.focus(); throw new Error('제목을 입력해 주세요.'); }
|
|
442
|
+
if (!content.value.trim()) { content.focus(); throw new Error('내용을 입력해 주세요.'); }
|
|
425
443
|
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
|
-
} }),
|
|
444
|
+
if (doc) await put(`/api/documents/${doc.id}`, body);
|
|
445
|
+
else await post('/api/documents', { ...body, source: 'manual' });
|
|
446
|
+
toastOk(doc ? '문서를 수정했어요.' : '문서를 추가했어요.');
|
|
447
|
+
close();
|
|
448
|
+
await reload();
|
|
449
|
+
}),
|
|
434
450
|
],
|
|
435
451
|
});
|
|
436
452
|
}
|
|
@@ -5,16 +5,9 @@
|
|
|
5
5
|
// the same "AI에게 보내면 저장됩니다" instruction across surfaces.
|
|
6
6
|
import {
|
|
7
7
|
el, get, icon, navigate, Card, Badge, Btn, EmptyState, ListRow, CheckRow,
|
|
8
|
-
Stat, PageHead, fmtDate, fmtRelative, scoreClass, mount,
|
|
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)
|
|
@@ -84,6 +77,15 @@ 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
90
|
el('p', { class: 'text-secondary text-sm', style: { margin: '12px 0 8px' } },
|
|
89
91
|
'완성할수록 AI의 적합도 분석과 자기소개서 품질이 좋아져요.'),
|
|
@@ -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) {
|
|
@@ -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); }
|
|
@@ -436,7 +439,7 @@ function CoverLettersCard(coverLetters) {
|
|
|
436
439
|
...list.map((cl) => el('div', {
|
|
437
440
|
class: 'flex between center is-clickable',
|
|
438
441
|
style: { padding: '8px 0', cursor: 'pointer' },
|
|
439
|
-
|
|
442
|
+
activate: () => navigate('/documents'),
|
|
440
443
|
},
|
|
441
444
|
el('div', { class: 'flex gap-2 center' },
|
|
442
445
|
icon('file', 'muted'),
|
|
@@ -484,7 +487,7 @@ function RelatedCard(related) {
|
|
|
484
487
|
...list.map((r) => el('div', {
|
|
485
488
|
class: 'flex between center is-clickable',
|
|
486
489
|
style: { padding: '8px 0', cursor: 'pointer' },
|
|
487
|
-
|
|
490
|
+
activate: () => navigate(`/jobs/${r.id}`),
|
|
488
491
|
},
|
|
489
492
|
el('div', {},
|
|
490
493
|
el('div', { class: 'strong' }, r.position),
|