careermate 0.0.7 → 0.0.9
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/AGENTS.md +2 -2
- package/LICENSE +1 -1
- package/README.md +108 -91
- package/THIRD_PARTY_NOTICES.md +2 -1
- package/bin/careermate.mjs +11 -2
- package/dist/career-os/eop/cover-letter.md +13 -6
- package/dist/career-os/eop/interview-prep.md +1 -0
- package/dist/career-os/knowledge/cover-letter.md +7 -3
- package/dist/career-os/knowledge/fit-matching.md +2 -0
- package/dist/career-os/knowledge/verifiers/ai-detection-risk.md +57 -0
- package/dist/career-os/knowledge/verifiers/human-voice.md +7 -2
- package/dist/career-os/knowledge/verifiers/truthfulness.md +1 -0
- package/dist/doctor.mjs +19760 -1018
- package/dist/mcp.mjs +19783 -1035
- package/dist/migrate.mjs +289 -20
- package/dist/public/i18n/en/applications.js +19 -0
- package/dist/public/i18n/en/common.js +10 -0
- package/dist/public/i18n/en/documents.js +0 -10
- package/dist/public/i18n/en/home.js +5 -0
- package/dist/public/i18n/en/jobs.js +18 -1
- package/dist/public/i18n/en/profile.js +0 -5
- package/dist/public/i18n/en/settings.js +5 -2
- package/dist/public/i18n/ko/applications.js +19 -0
- package/dist/public/i18n/ko/common.js +9 -0
- package/dist/public/i18n/ko/documents.js +0 -10
- package/dist/public/i18n/ko/home.js +5 -0
- package/dist/public/i18n/ko/jobs.js +18 -1
- package/dist/public/i18n/ko/profile.js +0 -5
- package/dist/public/i18n/ko/settings.js +5 -2
- package/dist/public/lib.js +101 -0
- package/dist/public/pages/applications.js +95 -15
- package/dist/public/pages/documents.js +20 -14
- package/dist/public/pages/home.js +38 -6
- package/dist/public/pages/interview.js +11 -4
- package/dist/public/pages/jobs.js +71 -1
- package/dist/public/pages/profile.js +2 -3
- package/dist/public/pages/settings.js +2 -2
- package/dist/public/styles.css +52 -4
- package/dist/web.mjs +19001 -758
- package/docs/FAQ.md +13 -3
- package/docs/MCP_TOOLS.md +32 -12
- package/docs/career-os/eop/cover-letter.md +13 -6
- package/docs/career-os/eop/interview-prep.md +1 -0
- package/docs/career-os/knowledge/cover-letter.md +7 -3
- package/docs/career-os/knowledge/fit-matching.md +2 -0
- package/docs/career-os/knowledge/verifiers/ai-detection-risk.md +57 -0
- package/docs/career-os/knowledge/verifiers/human-voice.md +7 -2
- package/docs/career-os/knowledge/verifiers/truthfulness.md +1 -0
- package/package.json +5 -2
- package/packages/core/src/index.ts +2 -0
- package/packages/core/src/jobsearch.ts +190 -0
- package/packages/core/src/offers.ts +41 -0
- package/packages/core/src/services.ts +29 -3
- package/packages/core/src/summary.ts +431 -9
- package/packages/core/src/verify/charcount.ts +87 -0
- package/packages/core/src/verify/index.ts +1 -0
- package/packages/core/src/verify/lint.ts +5 -0
- package/packages/db/src/connection.ts +70 -2
- package/packages/db/src/index.ts +1 -0
- package/packages/db/src/repositories.ts +179 -13
- package/packages/db/src/schema.ts +169 -8
- package/packages/exporters/src/docx.ts +165 -0
- package/packages/exporters/src/index.ts +142 -9
- package/packages/exporters/src/markdown.ts +16 -11
- package/packages/knowledge/src/index.ts +8 -3
- package/packages/mcp-tools/src/result.ts +9 -0
- package/packages/mcp-tools/src/tools.ts +303 -60
- package/packages/parsers/src/documents.ts +7 -1
- package/packages/prompts/src/humanize.ts +4 -0
- package/packages/shared/src/schemas.ts +198 -0
- package/scripts/init.ts +7 -0
|
@@ -9,6 +9,15 @@ export default {
|
|
|
9
9
|
'common.delete': '삭제',
|
|
10
10
|
'common.open': '열기',
|
|
11
11
|
'common.justNow': '방금 전',
|
|
12
|
+
'common.export': '내보내기',
|
|
13
|
+
'common.fmtWord': 'Word',
|
|
14
|
+
'common.fmtHtml': 'HTML',
|
|
15
|
+
'common.fmtMarkdown': 'Markdown',
|
|
16
|
+
|
|
17
|
+
// 전형 일정 D-day 배지(홈 액션레인 + 공고 상세 공유). 마감 D-day(applications.dday.*)와는 별개.
|
|
18
|
+
'schedule.dday.today': 'D-day',
|
|
19
|
+
'schedule.dday.future': 'D-{{n}}',
|
|
20
|
+
'schedule.dday.past': '지남',
|
|
12
21
|
|
|
13
22
|
'toast.copied': '클립보드에 복사했습니다.',
|
|
14
23
|
'toast.copyFailed': '복사에 실패했습니다. 직접 선택해 복사해 주세요.',
|
|
@@ -28,10 +28,6 @@ export default {
|
|
|
28
28
|
'documents.cover.primary': '대표',
|
|
29
29
|
'documents.cover.open': '열기',
|
|
30
30
|
'documents.cover.copy': '복사',
|
|
31
|
-
'documents.cover.exportMd': 'MD',
|
|
32
|
-
'documents.cover.exportHtml': 'HTML',
|
|
33
|
-
'documents.cover.exportMdTitle': 'Markdown 내보내기',
|
|
34
|
-
'documents.cover.exportHtmlTitle': 'HTML 내보내기',
|
|
35
31
|
'documents.cover.setPrimary': '대표 지정',
|
|
36
32
|
'documents.cover.delete': '삭제',
|
|
37
33
|
'documents.cover.noContent': '아직 내용이 없습니다.',
|
|
@@ -60,8 +56,6 @@ export default {
|
|
|
60
56
|
'documents.coverCreate.toast': '자기소개서를 추가했어요.',
|
|
61
57
|
|
|
62
58
|
// cover detail body
|
|
63
|
-
'documents.coverDetail.exportMd': 'MD 내보내기',
|
|
64
|
-
'documents.coverDetail.exportHtml': 'HTML 내보내기',
|
|
65
59
|
'documents.coverDetail.currentVersion': '현재 버전',
|
|
66
60
|
'documents.coverDetail.copy': '복사',
|
|
67
61
|
'documents.coverDetail.noContent': '내용이 없습니다.',
|
|
@@ -101,10 +95,6 @@ export default {
|
|
|
101
95
|
'documents.docDetail.updated': '수정 {{when}}',
|
|
102
96
|
'documents.docDetail.noContent': '내용이 없습니다.',
|
|
103
97
|
'documents.docDetail.copy': '복사',
|
|
104
|
-
'documents.docDetail.export': 'MD',
|
|
105
|
-
'documents.docDetail.exportTitle': 'Markdown 내보내기',
|
|
106
|
-
'documents.docDetail.exportHtml': 'HTML',
|
|
107
|
-
'documents.docDetail.exportHtmlTitle': 'HTML 내보내기',
|
|
108
98
|
'documents.docDetail.delete': '삭제',
|
|
109
99
|
'documents.docDetail.deleteTitle': '문서 삭제',
|
|
110
100
|
'documents.docDetail.deleteConfirm': '"{{title}}"을(를) 삭제할까요?',
|
|
@@ -28,6 +28,11 @@ export default {
|
|
|
28
28
|
'home.stat.activeApplications': '진행 중 지원',
|
|
29
29
|
'home.stat.coverLetters': '자기소개서',
|
|
30
30
|
'home.stat.interviewPending': '면접 준비',
|
|
31
|
+
// KPI tile context sub-lines (derived from real counts; omitted when zero)
|
|
32
|
+
'home.stat.jobsSub': '지원 전 {{count}}건',
|
|
33
|
+
'home.stat.subInterview': '면접 {{count}}',
|
|
34
|
+
'home.stat.subDocPassed': '서류 {{count}}',
|
|
35
|
+
'home.stat.subApplied': '지원 {{count}}',
|
|
31
36
|
|
|
32
37
|
// pipeline
|
|
33
38
|
'home.pipeline.title': '지원 파이프라인',
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
export default {
|
|
3
3
|
// ---- list mode ----
|
|
4
4
|
'jobs.list.empty.title': '저장된 공고가 없어요',
|
|
5
|
-
'jobs.list.empty.body': '관심 있는 채용공고를 추가해 적합도와 진행 상태를 관리해 보세요.',
|
|
5
|
+
'jobs.list.empty.body': '관심 있는 채용공고를 추가해 적합도와 진행 상태를 관리해 보세요. 연결된 AI에게 “백엔드 공고 찾아줘”처럼 말하면 공고를 찾아 여기에 저장해 줍니다.',
|
|
6
6
|
'jobs.list.addJob': '공고 추가',
|
|
7
7
|
'jobs.list.searchPlaceholder': '회사·직무 검색',
|
|
8
8
|
'jobs.list.searchAria': '공고 검색',
|
|
@@ -73,6 +73,11 @@ export default {
|
|
|
73
73
|
'jobs.detail.openPosting': '공고 원문',
|
|
74
74
|
|
|
75
75
|
// ---- posting card ----
|
|
76
|
+
'jobs.company.title': '회사 리서치',
|
|
77
|
+
'jobs.company.overview': '사업 개요',
|
|
78
|
+
'jobs.company.talent': '인재상',
|
|
79
|
+
'jobs.company.values': '핵심가치',
|
|
80
|
+
'jobs.company.reputation': '평판',
|
|
76
81
|
'jobs.posting.title': '공고 정보',
|
|
77
82
|
'jobs.posting.location': '위치',
|
|
78
83
|
'jobs.posting.employment': '고용형태',
|
|
@@ -85,6 +90,18 @@ export default {
|
|
|
85
90
|
'jobs.posting.requirements': '자격요건 · 우대사항',
|
|
86
91
|
'jobs.posting.rawText': '공고 원문',
|
|
87
92
|
|
|
93
|
+
// ---- cover letter questions (문항형 자소서) ----
|
|
94
|
+
'jobs.questions.title': '자소서 문항',
|
|
95
|
+
'jobs.questions.min': '최소 {{n}}{{unit}}',
|
|
96
|
+
'jobs.questions.max': '최대 {{n}}{{unit}}',
|
|
97
|
+
'jobs.questions.unitChar': '자',
|
|
98
|
+
'jobs.questions.unitByte': 'byte',
|
|
99
|
+
'jobs.questions.modeWithSpace': '(공백포함)',
|
|
100
|
+
'jobs.questions.modeNoSpace': '(공백제외)',
|
|
101
|
+
|
|
102
|
+
// ---- hiring schedule (전형 일정) ---- (D-day 배지는 공용 schedule.dday.* 사용)
|
|
103
|
+
'jobs.schedule.title': '전형 일정',
|
|
104
|
+
|
|
88
105
|
// ---- fit card ----
|
|
89
106
|
'jobs.fit.title': '적합도 분석',
|
|
90
107
|
'jobs.fit.emptyBody': '아직 분석 전이에요. 이 공고와 내 프로필을 비교한 결과가 여기에 표시됩니다.',
|
|
@@ -11,11 +11,6 @@ export default {
|
|
|
11
11
|
'profile.completeness.title': '프로필 완성도',
|
|
12
12
|
'profile.completeness.percent': '{{pct}}%',
|
|
13
13
|
|
|
14
|
-
// export profile as a resume
|
|
15
|
-
'profile.export.md': 'MD',
|
|
16
|
-
'profile.export.html': 'HTML',
|
|
17
|
-
'profile.export.mdTitle': '이력서 Markdown 내보내기',
|
|
18
|
-
'profile.export.htmlTitle': '이력서 HTML 내보내기',
|
|
19
14
|
|
|
20
15
|
// basic info card
|
|
21
16
|
'profile.basic.title': '기본 정보',
|
|
@@ -105,18 +105,21 @@ export default {
|
|
|
105
105
|
'settings.import.confirm': '가져오기',
|
|
106
106
|
'settings.import.doneToast': '데이터를 가져왔어요.',
|
|
107
107
|
|
|
108
|
+
// 언어
|
|
109
|
+
'settings.lang.sub': '대시보드 표시 언어',
|
|
110
|
+
|
|
108
111
|
// 위험 구역
|
|
109
112
|
'settings.reset.gateWord': '초기화',
|
|
110
113
|
'settings.reset.title': '모든 데이터 초기화',
|
|
111
114
|
'settings.reset.calloutTitle': '되돌릴 수 없습니다',
|
|
112
|
-
'settings.reset.calloutBody': '프로필·공고·지원·문서·면접 준비를 포함한 모든 데이터가 영구적으로 삭제됩니다. 안전을 위해 초기화 직전에 자동으로 백업이 한 번
|
|
115
|
+
'settings.reset.calloutBody': '프로필·공고·지원·문서·면접 준비를 포함한 모든 데이터가 영구적으로 삭제됩니다. 안전을 위해 초기화 직전에 자동으로 백업이 한 번 생성되며, 이 백업 파일은 데이터 폴더의 backups에 남습니다 — 흔적 없이 지우려면 초기화 후 그 파일도 직접 삭제하세요.',
|
|
113
116
|
'settings.reset.instruction': '계속하려면 아래 입력란에 {{word}} 를 입력하세요.',
|
|
114
117
|
'settings.reset.confirm': '모든 데이터 초기화',
|
|
115
118
|
'settings.reset.doneToast': '모든 데이터를 초기화했어요.',
|
|
116
119
|
'settings.danger.title': '위험 구역',
|
|
117
120
|
'settings.danger.sub': '이 작업은 되돌릴 수 없습니다',
|
|
118
121
|
'settings.danger.resetTitle': '모든 데이터 초기화',
|
|
119
|
-
'settings.danger.resetBody': '이 컴퓨터에 저장된 모든 CareerMate 데이터를 삭제합니다. 초기화 전에
|
|
122
|
+
'settings.danger.resetBody': '이 컴퓨터에 저장된 모든 CareerMate 데이터를 삭제합니다. 초기화 전에 자동 백업이 생성되어 backups 폴더에 남습니다.',
|
|
120
123
|
|
|
121
124
|
// help / report a problem
|
|
122
125
|
'settings.support.title': '도움말·문제 신고',
|
package/dist/public/lib.js
CHANGED
|
@@ -184,6 +184,24 @@ export function scoreClass(score) {
|
|
|
184
184
|
if (score >= 50) return 'score-mid';
|
|
185
185
|
return 'score-weak';
|
|
186
186
|
}
|
|
187
|
+
/* --------------------------------------------------------------- date math */
|
|
188
|
+
// Local-calendar helpers. The server (core daysUntilDate) computes D-days from LOCAL
|
|
189
|
+
// midnight; mirror that here so the dashboard never disagrees by a day near midnight
|
|
190
|
+
// or across timezones — toISOString() would use UTC and can drift the date.
|
|
191
|
+
/** Today as a local YYYY-MM-DD string (not UTC). */
|
|
192
|
+
export function todayLocalISO() {
|
|
193
|
+
const d = new Date();
|
|
194
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
195
|
+
}
|
|
196
|
+
/** Whole days from local midnight today to a YYYY-MM-DD date (future > 0, today = 0, past < 0); null if unparseable. */
|
|
197
|
+
export function daysUntil(dateStr) {
|
|
198
|
+
if (!dateStr) return null;
|
|
199
|
+
const d = new Date(`${dateStr}T00:00:00`);
|
|
200
|
+
if (isNaN(d.getTime())) return null;
|
|
201
|
+
const today = new Date();
|
|
202
|
+
today.setHours(0, 0, 0, 0);
|
|
203
|
+
return Math.round((d.getTime() - today.getTime()) / 86400000);
|
|
204
|
+
}
|
|
187
205
|
/** Textarea (newline-separated) → trimmed array, empties dropped. */
|
|
188
206
|
export function linesToArray(str) { return String(str || '').split('\n').map((s) => s.trim()).filter(Boolean); }
|
|
189
207
|
/** CSV ("a, b, c") → trimmed array, empties dropped. */
|
|
@@ -208,6 +226,17 @@ export function navigate(hash) { location.hash = hash.startsWith('#') ? hash : '
|
|
|
208
226
|
export function Badge(status, label) {
|
|
209
227
|
return el('span', { class: `badge badge--${status}` }, el('span', { class: 'dot' }), label);
|
|
210
228
|
}
|
|
229
|
+
/**
|
|
230
|
+
* Hiring-schedule stage D-day badge (면접·인적성·발표 date), shared by the Home action
|
|
231
|
+
* lane and the job-detail schedule list so label + colour never diverge: past = grey
|
|
232
|
+
* "지남", today = red "D-day", future = amber "D-{{n}}". Pass a D-day computed with the
|
|
233
|
+
* same local-midnight rule as the server (see daysUntil / core daysUntilDate).
|
|
234
|
+
*/
|
|
235
|
+
export function scheduleDdayBadge(dDay) {
|
|
236
|
+
if (dDay < 0) return Badge('draft', t('schedule.dday.past'));
|
|
237
|
+
if (dDay === 0) return Badge('rejected', t('schedule.dday.today'));
|
|
238
|
+
return Badge('interview', t('schedule.dday.future', { n: dDay }));
|
|
239
|
+
}
|
|
211
240
|
export function Btn(label, { icon: ic, variant = '', sm, onClick, type, disabled, title } = {}) {
|
|
212
241
|
const cls = ['btn', variant && `btn--${variant}`, sm && 'btn--sm'].filter(Boolean).join(' ');
|
|
213
242
|
return el('button', { class: cls, onClick, type: type || 'button', disabled, title }, ic && icon(ic), label && el('span', {}, label));
|
|
@@ -215,6 +244,78 @@ export function Btn(label, { icon: ic, variant = '', sm, onClick, type, disabled
|
|
|
215
244
|
export function IconBtn(name, { onClick, title, variant = 'ghost' } = {}) {
|
|
216
245
|
return el('button', { class: `btn btn--${variant} icon-btn`, onClick, title, attrs: { 'aria-label': title || name } }, icon(name));
|
|
217
246
|
}
|
|
247
|
+
/**
|
|
248
|
+
* A dropdown: one trigger button that opens a small popover of actions, so
|
|
249
|
+
* several secondary actions (e.g. export as docx/html/md) collapse into a single
|
|
250
|
+
* control instead of a row of competing buttons. The popover is position:fixed
|
|
251
|
+
* and anchored to the trigger's rect, so it never clips inside a scrolling modal
|
|
252
|
+
* body and flips above the trigger when there's no room below. Closes on outside
|
|
253
|
+
* click, Escape, scroll, resize, or item activation.
|
|
254
|
+
*
|
|
255
|
+
* `items`: array of `{ label, hint?, icon?, onClick, danger? }` (falsy entries skipped).
|
|
256
|
+
*/
|
|
257
|
+
export function Menu(label, items, { icon: ic = 'download', variant = 'ghost', sm, align = 'end' } = {}) {
|
|
258
|
+
const list = el('div', { class: 'menu__list', attrs: { role: 'menu' }, hidden: true });
|
|
259
|
+
for (const it of items) {
|
|
260
|
+
if (!it) continue;
|
|
261
|
+
list.append(el('button', {
|
|
262
|
+
class: `menu__item${it.danger ? ' menu__item--danger' : ''}`,
|
|
263
|
+
type: 'button', attrs: { role: 'menuitem' },
|
|
264
|
+
onClick: () => { close(); it.onClick?.(); },
|
|
265
|
+
}, it.icon && icon(it.icon), el('span', { class: 'menu__item-label' }, it.label), it.hint && el('span', { class: 'menu__item-hint' }, it.hint)));
|
|
266
|
+
}
|
|
267
|
+
const trigger = el('button', {
|
|
268
|
+
class: ['btn', variant && `btn--${variant}`, sm && 'btn--sm', 'menu__trigger'].filter(Boolean).join(' '),
|
|
269
|
+
type: 'button', attrs: { 'aria-haspopup': 'menu', 'aria-expanded': 'false' },
|
|
270
|
+
onClick: () => (list.hidden ? open() : close()),
|
|
271
|
+
}, ic && icon(ic), label && el('span', {}, label), icon('chevronRight', 'menu__caret'));
|
|
272
|
+
const wrap = el('div', { class: 'menu' }, trigger, list);
|
|
273
|
+
|
|
274
|
+
function place() {
|
|
275
|
+
const r = trigger.getBoundingClientRect();
|
|
276
|
+
const lw = list.offsetWidth, lh = list.offsetHeight;
|
|
277
|
+
let top = r.bottom + 6;
|
|
278
|
+
if (top + lh > window.innerHeight - 8 && r.top - 6 - lh > 8) top = r.top - 6 - lh; // flip up
|
|
279
|
+
let left = align === 'end' ? r.right - lw : r.left;
|
|
280
|
+
left = Math.max(8, Math.min(left, window.innerWidth - lw - 8));
|
|
281
|
+
list.style.top = `${Math.round(top)}px`;
|
|
282
|
+
list.style.left = `${Math.round(left)}px`;
|
|
283
|
+
}
|
|
284
|
+
function open() {
|
|
285
|
+
if (!list.hidden) return;
|
|
286
|
+
list.hidden = false; place(); // place() reads layout while visible, same tick → no flash
|
|
287
|
+
trigger.setAttribute('aria-expanded', 'true');
|
|
288
|
+
document.addEventListener('click', onDoc, true);
|
|
289
|
+
document.addEventListener('keydown', onKey, true);
|
|
290
|
+
window.addEventListener('scroll', close, true);
|
|
291
|
+
window.addEventListener('resize', close);
|
|
292
|
+
}
|
|
293
|
+
function close() {
|
|
294
|
+
if (list.hidden) return;
|
|
295
|
+
list.hidden = true;
|
|
296
|
+
trigger.setAttribute('aria-expanded', 'false');
|
|
297
|
+
document.removeEventListener('click', onDoc, true);
|
|
298
|
+
document.removeEventListener('keydown', onKey, true);
|
|
299
|
+
window.removeEventListener('scroll', close, true);
|
|
300
|
+
window.removeEventListener('resize', close);
|
|
301
|
+
}
|
|
302
|
+
function onDoc(e) { if (!wrap.contains(e.target)) close(); }
|
|
303
|
+
function onKey(e) { if (e.key === 'Escape') { close(); trigger.focus(); } }
|
|
304
|
+
return wrap;
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* The standard "내보내기" dropdown for a document export endpoint. `baseUrl` is a
|
|
308
|
+
* GET export route (e.g. `/api/export/profile`); each item appends `?format=…`
|
|
309
|
+
* and triggers a download. One control replaces the old docx/html/md button row.
|
|
310
|
+
*/
|
|
311
|
+
export function ExportMenu(baseUrl, { sm = true } = {}) {
|
|
312
|
+
const sep = baseUrl.includes('?') ? '&' : '?';
|
|
313
|
+
return Menu(t('common.export'), [
|
|
314
|
+
{ label: t('common.fmtWord'), hint: '.docx', onClick: () => downloadUrl(`${baseUrl}${sep}format=docx`) },
|
|
315
|
+
{ label: t('common.fmtHtml'), hint: '.html', onClick: () => downloadUrl(`${baseUrl}${sep}format=html`) },
|
|
316
|
+
{ label: t('common.fmtMarkdown'), hint: '.md', onClick: () => downloadUrl(`${baseUrl}${sep}format=md`) },
|
|
317
|
+
], { sm });
|
|
318
|
+
}
|
|
218
319
|
/**
|
|
219
320
|
* A footer button for async saves. While `action()` runs the button is disabled
|
|
220
321
|
* (so a slow local round-trip can't be double-clicked into duplicate records),
|
|
@@ -1,19 +1,16 @@
|
|
|
1
1
|
// Applications — every application grouped by status in a calm VERTICAL list
|
|
2
2
|
// (no horizontal scroll). Change a status inline to move an item between groups.
|
|
3
3
|
import {
|
|
4
|
-
el, get, put, navigate, EmptyState, Btn, ListRow,
|
|
5
|
-
Select, scoreClass, statusColor, mount, toast, toastOk, toastError,
|
|
4
|
+
el, get, put, navigate, EmptyState, Btn, ListRow, Badge, Card,
|
|
5
|
+
Select, scoreClass, statusColor, mount, toast, toastOk, toastError, todayLocalISO, daysUntil,
|
|
6
6
|
} from '/lib.js';
|
|
7
7
|
import { t } from '/i18n.js';
|
|
8
8
|
|
|
9
|
-
// deadline(YYYY-MM-DD) → "Due in 7d" / "Due today" / "Overdue" (null if absent/invalid)
|
|
9
|
+
// deadline(YYYY-MM-DD) → "Due in 7d" / "Due today" / "Overdue" (null if absent/invalid).
|
|
10
|
+
// daysUntil shares the server's local-midnight rule (core daysUntilDate).
|
|
10
11
|
function ddayLabel(deadline) {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
if (isNaN(d.getTime())) return null;
|
|
14
|
-
const today = new Date();
|
|
15
|
-
today.setHours(0, 0, 0, 0);
|
|
16
|
-
const days = Math.round((d.getTime() - today.getTime()) / 86400000);
|
|
12
|
+
const days = daysUntil(deadline);
|
|
13
|
+
if (days == null) return null;
|
|
17
14
|
if (days > 0) return t('applications.dday.future', { n: days });
|
|
18
15
|
if (days === 0) return t('applications.dday.today');
|
|
19
16
|
return t('applications.dday.past');
|
|
@@ -21,9 +18,10 @@ function ddayLabel(deadline) {
|
|
|
21
18
|
|
|
22
19
|
export async function render(ctx) {
|
|
23
20
|
ctx.setActions([]);
|
|
24
|
-
const data = await get('/api/applications');
|
|
21
|
+
const [data, offersRes] = await Promise.all([get('/api/applications'), get('/api/offers')]);
|
|
25
22
|
const applications = data.applications || [];
|
|
26
23
|
const boardOrder = data.board_order || [];
|
|
24
|
+
const offers = (offersRes && offersRes.offers) || [];
|
|
27
25
|
|
|
28
26
|
if (!applications.length) {
|
|
29
27
|
mount(ctx.view, el('div', { class: 'stack-4' }, EmptyState({
|
|
@@ -43,6 +41,7 @@ export async function render(ctx) {
|
|
|
43
41
|
for (const app of applications) (byStatus[app.status] || (byStatus[app.status] = [])).push(app);
|
|
44
42
|
|
|
45
43
|
const wrap = el('div', { class: 'stack-4' });
|
|
44
|
+
if (offers.length) wrap.append(OffersSection(offers));
|
|
46
45
|
for (const code of boardOrder) {
|
|
47
46
|
const apps = byStatus[code];
|
|
48
47
|
if (apps && apps.length) wrap.append(StatusGroup(code, apps));
|
|
@@ -63,23 +62,31 @@ export async function render(ctx) {
|
|
|
63
62
|
const subBits = [job.position, job.location].filter(Boolean);
|
|
64
63
|
const dday = ddayLabel(job.deadline);
|
|
65
64
|
if (dday) subBits.push(dday);
|
|
65
|
+
// 탈락 회고: 불합격이고 단계가 기록돼 있으면 어느 단계에서 떨어졌는지 sub에 가볍게 병기.
|
|
66
|
+
if (app.status === 'rejected' && app.rejected_stage) {
|
|
67
|
+
subBits.push(`${t('applications.rejectedStage.label')} ${t('applications.rejectedStage.' + app.rejected_stage)}`);
|
|
68
|
+
}
|
|
66
69
|
const sel = Select(
|
|
67
70
|
statusOptions.map((o) => ({ ...o, selected: o.value === app.status })),
|
|
68
71
|
{
|
|
69
|
-
class: 'select select--sm
|
|
72
|
+
class: 'select select--sm',
|
|
70
73
|
title: t('applications.statusSelect.title'),
|
|
71
74
|
onClick: (e) => e.stopPropagation(),
|
|
72
75
|
onChange: (e) => { e.stopPropagation(); changeStatus(app, e.target.value, e.target); },
|
|
73
76
|
},
|
|
74
77
|
);
|
|
78
|
+
// Keep the right edge informative on every row: a right-aligned score column
|
|
79
|
+
// (a muted dash when not yet scored, so scores line up and "no score" reads
|
|
80
|
+
// clearly), then the status — a calm badge that swaps to the change-select on hover.
|
|
81
|
+
const score = app.fit_score != null
|
|
82
|
+
? el('span', { class: `app-score strong tnum ${scoreClass(app.fit_score)}` }, t('applications.row.score', { score: app.fit_score }))
|
|
83
|
+
: el('span', { class: 'app-score muted tnum' }, '—');
|
|
75
84
|
return ListRow({
|
|
76
85
|
title: job.company || '—',
|
|
77
86
|
sub: subBits.join(' · '),
|
|
78
87
|
trailing: [
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
: null,
|
|
82
|
-
sel,
|
|
88
|
+
score,
|
|
89
|
+
el('span', { class: 'app-status' }, Badge(app.status, t('status.' + app.status)), sel),
|
|
83
90
|
],
|
|
84
91
|
onClick: () => navigate(`/jobs/${app.job_id}`),
|
|
85
92
|
});
|
|
@@ -108,3 +115,76 @@ export async function render(ctx) {
|
|
|
108
115
|
}
|
|
109
116
|
}
|
|
110
117
|
}
|
|
118
|
+
|
|
119
|
+
// Offers — sorted by the AI's score (server pre-sorts). The score + verdict were
|
|
120
|
+
// written by the connected AI at save time, so this renders without any LLM. The
|
|
121
|
+
// precise head-to-head comparison happens in the AI chat via compare_offers.
|
|
122
|
+
//
|
|
123
|
+
// Lifecycle-aware: only OPEN offers deserve the top of the pipeline (they carry
|
|
124
|
+
// accept deadlines — the most time-sensitive decision in the whole search).
|
|
125
|
+
// Accepted/declined offers are kept as career records (terms history feeds the
|
|
126
|
+
// next negotiation) but demoted to a collapsed "past offers" line.
|
|
127
|
+
function OffersSection(offers) {
|
|
128
|
+
const status = (o) => o.offer_status || 'open';
|
|
129
|
+
const active = offers.filter((o) => status(o) === 'open');
|
|
130
|
+
const past = offers.filter((o) => status(o) !== 'open');
|
|
131
|
+
const children = [];
|
|
132
|
+
if (active.length) {
|
|
133
|
+
children.push(
|
|
134
|
+
el('div', { class: 'app-group__head' },
|
|
135
|
+
el('span', { class: 'app-group__title' }, t('applications.offers.title')),
|
|
136
|
+
el('span', { class: 'app-group__count tnum' }, t('applications.group.count', { count: active.length }))),
|
|
137
|
+
...active.map(OfferCard),
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
if (past.length) children.push(PastOffers(past));
|
|
141
|
+
return el('div', { class: 'stack-3' }, ...children);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function OfferCard(o) {
|
|
145
|
+
const meta = [];
|
|
146
|
+
if (o.total_comp_annual_est != null)
|
|
147
|
+
meta.push(`${t('applications.offers.est')} ${o.total_comp_annual_est.toLocaleString()}${t('applications.offers.unit')}`);
|
|
148
|
+
if (o.work_arrangement) meta.push(o.work_arrangement);
|
|
149
|
+
if (o.accept_deadline) meta.push(t('applications.offers.acceptBy', { date: o.accept_deadline }));
|
|
150
|
+
const score = o.ai_score != null
|
|
151
|
+
? el('span', { class: `strong tnum ${scoreClass(o.ai_score)}` }, String(o.ai_score))
|
|
152
|
+
: el('span', { class: 'muted' }, t('applications.offers.noScore'));
|
|
153
|
+
// Deadline passed on a still-open offer: flag it, never auto-hide — deadlines
|
|
154
|
+
// get extended in negotiation, so the call is the user's (or the AI's), not ours.
|
|
155
|
+
const expired = !!o.accept_deadline && o.accept_deadline < todayLocalISO();
|
|
156
|
+
const actions = expired
|
|
157
|
+
? el('span', { class: 'flex gap-2', style: { alignItems: 'center' } },
|
|
158
|
+
Badge('on_hold', t('applications.offers.expired')), score)
|
|
159
|
+
: score;
|
|
160
|
+
return Card({
|
|
161
|
+
title: o.company,
|
|
162
|
+
sub: meta.join(' · '),
|
|
163
|
+
actions,
|
|
164
|
+
body: o.verdict ? el('p', { class: 'muted', style: { marginTop: '6px' } }, o.verdict) : null,
|
|
165
|
+
clickable: true,
|
|
166
|
+
onClick: () => navigate(`/jobs/${o.job_id}`),
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Accepted/declined offers, one compact row each inside a collapsed disclosure.
|
|
171
|
+
function PastOffers(past) {
|
|
172
|
+
const rows = past.map((o) =>
|
|
173
|
+
el('div', { class: 'flex gap-2', style: { alignItems: 'center', padding: '8px 0' } },
|
|
174
|
+
Badge(o.offer_status === 'accepted' ? 'final_passed' : 'rejected',
|
|
175
|
+
t(`applications.offers.status.${o.offer_status}`)),
|
|
176
|
+
el('span', {
|
|
177
|
+
class: 'strong',
|
|
178
|
+
style: { cursor: 'pointer' },
|
|
179
|
+
activate: () => navigate(`/jobs/${o.job_id}`),
|
|
180
|
+
}, o.company),
|
|
181
|
+
o.total_comp_annual_est != null
|
|
182
|
+
? el('span', { class: 'muted text-sm' },
|
|
183
|
+
`${t('applications.offers.est')} ${o.total_comp_annual_est.toLocaleString()}${t('applications.offers.unit')}`)
|
|
184
|
+
: null,
|
|
185
|
+
));
|
|
186
|
+
return el('details', {},
|
|
187
|
+
el('summary', { class: 'muted text-sm', style: { cursor: 'pointer' } },
|
|
188
|
+
t('applications.offers.past', { count: past.length })),
|
|
189
|
+
...rows);
|
|
190
|
+
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import {
|
|
4
4
|
el, get, post, put, del, Card, Badge, Btn, IconBtn, SubmitBtn, EmptyState,
|
|
5
5
|
Field, Input, Textarea, Select, openModal, confirmDialog,
|
|
6
|
-
toastOk, toastError, copyText,
|
|
6
|
+
toastOk, toastError, copyText, fmtRelative, mount, meta, ExportMenu,
|
|
7
7
|
} from '/lib.js';
|
|
8
8
|
import { t } from '/i18n.js';
|
|
9
9
|
|
|
@@ -21,19 +21,28 @@ export async function render(ctx) {
|
|
|
21
21
|
const root = el('div', { class: 'stack-4' });
|
|
22
22
|
|
|
23
23
|
const panel = el('div', {});
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
const
|
|
27
|
-
|
|
24
|
+
// Tabs are keyboard-operable (activate = role/tabindex/Enter·Space), with tab
|
|
25
|
+
// semantics for AT — role은 attrs가 먼저 tab으로 지정하므로 activate의 button 기본값을 덮지 않는다.
|
|
26
|
+
const makeTab = (key, label) =>
|
|
27
|
+
el('div', {
|
|
28
|
+
class: `tab${tab === key ? ' is-active' : ''}`,
|
|
29
|
+
attrs: { role: 'tab', 'aria-selected': String(tab === key) },
|
|
30
|
+
activate: () => selectTab(key),
|
|
31
|
+
}, label);
|
|
32
|
+
const tabCover = makeTab('cover', t('documents.tab.cover'));
|
|
33
|
+
const tabCareer = makeTab('career', t('documents.tab.career'));
|
|
34
|
+
const tabDocs = makeTab('docs', t('documents.tab.docs'));
|
|
35
|
+
root.append(el('div', { class: 'tabs', attrs: { role: 'tablist' } }, tabCover, tabCareer, tabDocs), panel);
|
|
28
36
|
|
|
29
37
|
let query = '';
|
|
30
38
|
let searchInput = null;
|
|
31
39
|
function selectTab(next) {
|
|
32
40
|
tab = next;
|
|
33
41
|
query = '';
|
|
34
|
-
tabCover
|
|
35
|
-
|
|
36
|
-
|
|
42
|
+
for (const [node, key] of [[tabCover, 'cover'], [tabCareer, 'career'], [tabDocs, 'docs']]) {
|
|
43
|
+
node.classList.toggle('is-active', tab === key);
|
|
44
|
+
node.setAttribute('aria-selected', String(tab === key));
|
|
45
|
+
}
|
|
37
46
|
setActions();
|
|
38
47
|
renderPanel();
|
|
39
48
|
}
|
|
@@ -138,8 +147,7 @@ function CoverRow(cl, reload) {
|
|
|
138
147
|
const actions = el('div', { class: 'flex gap-2 wrap' },
|
|
139
148
|
Btn(t('documents.cover.open'), { sm: true, icon: 'external', onClick: () => openCoverDetail(cl.id, reload) }),
|
|
140
149
|
Btn(t('documents.cover.copy'), { sm: true, variant: 'ghost', icon: 'copy', onClick: () => copyText(cl.current_content || ''), disabled: !cl.current_content }),
|
|
141
|
-
|
|
142
|
-
Btn(t('documents.cover.exportHtml'), { sm: true, variant: 'ghost', icon: 'download', title: t('documents.cover.exportHtmlTitle'), onClick: () => downloadUrl(`/api/export/cover-letter/${cl.id}?format=html`) }),
|
|
150
|
+
ExportMenu(`/api/export/cover-letter/${cl.id}`),
|
|
143
151
|
cl.is_primary ? null : Btn(t('documents.cover.setPrimary'), { sm: true, variant: 'ghost', icon: 'check', onClick: () => setPrimaryCover(cl.id, reload) }),
|
|
144
152
|
IconBtn('trash', { variant: 'danger', title: t('documents.cover.delete'), onClick: () => removeCover(cl, reload) }),
|
|
145
153
|
);
|
|
@@ -255,8 +263,7 @@ function CoverDetailBody(cl, { refresh }) {
|
|
|
255
263
|
cl.is_primary ? Badge('accent', t('documents.cover.primary')) : null,
|
|
256
264
|
),
|
|
257
265
|
el('div', { class: 'flex gap-2 wrap' },
|
|
258
|
-
|
|
259
|
-
Btn(t('documents.coverDetail.exportHtml'), { sm: true, variant: 'ghost', icon: 'download', onClick: () => downloadUrl(`/api/export/cover-letter/${cl.id}?format=html`) }),
|
|
266
|
+
ExportMenu(`/api/export/cover-letter/${cl.id}`),
|
|
260
267
|
),
|
|
261
268
|
));
|
|
262
269
|
|
|
@@ -446,8 +453,7 @@ async function openDocDetail(id, kindLabels, m, reload) {
|
|
|
446
453
|
),
|
|
447
454
|
footer: (close) => [
|
|
448
455
|
Btn(t('documents.docDetail.copy'), { variant: 'ghost', icon: 'copy', onClick: () => copyText(doc.content || '') }),
|
|
449
|
-
|
|
450
|
-
Btn(t('documents.docDetail.exportHtml'), { variant: 'ghost', icon: 'download', title: t('documents.docDetail.exportHtmlTitle'), onClick: () => downloadUrl(`/api/export/document/${doc.id}?format=html`) }),
|
|
456
|
+
ExportMenu(`/api/export/document/${doc.id}`, { sm: false }),
|
|
451
457
|
Btn(t('documents.docDetail.delete'), { variant: 'danger', icon: 'trash', onClick: async () => {
|
|
452
458
|
const ok = await confirmDialog({ title: t('documents.docDetail.deleteTitle'), message: t('documents.docDetail.deleteConfirm', { title: doc.title }), confirmLabel: t('documents.docDetail.deleteConfirmLabel'), danger: true });
|
|
453
459
|
if (!ok) return;
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import {
|
|
7
7
|
el, get, icon, navigate, Card, Badge, Btn, ListRow, CheckRow,
|
|
8
8
|
Stat, PageHead, fmtDate, fmtRelative, scoreClass, STATUS_COLOR, statusColor, mount,
|
|
9
|
+
scheduleDdayBadge,
|
|
9
10
|
} from '/lib.js';
|
|
10
11
|
import { t } from '/i18n.js';
|
|
11
12
|
|
|
@@ -45,7 +46,7 @@ export async function render(ctx) {
|
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
if (!s.onboarding.completed) wrap.append(GettingStarted(s.onboarding));
|
|
48
|
-
wrap.append(StatRow(s
|
|
49
|
+
wrap.append(StatRow(s));
|
|
49
50
|
wrap.append(Pipeline(s.status_breakdown));
|
|
50
51
|
wrap.append(el('div', { class: 'grid grid--2' },
|
|
51
52
|
el('div', { class: 'stack-4' }, ActionLane(s), RecentJobs(s.recent_jobs)),
|
|
@@ -96,15 +97,35 @@ function GettingStarted(o) {
|
|
|
96
97
|
}
|
|
97
98
|
|
|
98
99
|
/* ----------------------------------------------- KPI summary tiles (at-a-glance) */
|
|
99
|
-
function StatTile(label, value, iconName, to) {
|
|
100
|
-
const tile = Stat({ label, value, iconName, onClick: () => navigate(to) });
|
|
100
|
+
function StatTile(label, value, iconName, to, hint) {
|
|
101
|
+
const tile = Stat({ label, value, hint, iconName, onClick: () => navigate(to) });
|
|
101
102
|
tile.classList.add('is-clickable');
|
|
102
103
|
return tile;
|
|
103
104
|
}
|
|
104
|
-
function StatRow(
|
|
105
|
+
function StatRow(s) {
|
|
106
|
+
const counts = s.counts;
|
|
107
|
+
const by = Object.fromEntries((s.status_breakdown || []).map((b) => [b.status, b.count]));
|
|
108
|
+
const c = (st) => by[st] || 0;
|
|
109
|
+
|
|
110
|
+
// Contextual sub-lines use only real counts (never invented). Tiles with no
|
|
111
|
+
// meaningful secondary signal (cover letters, interview prep) stay clean — the
|
|
112
|
+
// .stat min-height keeps the row even either way.
|
|
113
|
+
|
|
114
|
+
// 저장 공고 → how many postings still await action (not yet applied).
|
|
115
|
+
const notApplied = c('draft') + c('planned');
|
|
116
|
+
const jobsHint = notApplied > 0 ? t('home.stat.jobsSub', { count: notApplied }) : null;
|
|
117
|
+
|
|
118
|
+
// 진행 중 지원 → surface the most advanced active stages (interview → screening
|
|
119
|
+
// → applied), up to two, so the number carries a "where are they" breakdown.
|
|
120
|
+
const activeHint = [
|
|
121
|
+
c('interview') ? t('home.stat.subInterview', { count: c('interview') }) : null,
|
|
122
|
+
c('document_passed') ? t('home.stat.subDocPassed', { count: c('document_passed') }) : null,
|
|
123
|
+
c('applied') ? t('home.stat.subApplied', { count: c('applied') }) : null,
|
|
124
|
+
].filter(Boolean).slice(0, 2).join(' · ') || null;
|
|
125
|
+
|
|
105
126
|
return el('div', { class: 'grid grid--4' },
|
|
106
|
-
StatTile(t('home.stat.jobs'), counts.jobs, 'briefcase', '/jobs'),
|
|
107
|
-
StatTile(t('home.stat.activeApplications'), counts.active_applications, 'layers', '/applications'),
|
|
127
|
+
StatTile(t('home.stat.jobs'), counts.jobs, 'briefcase', '/jobs', jobsHint),
|
|
128
|
+
StatTile(t('home.stat.activeApplications'), counts.active_applications, 'layers', '/applications', activeHint),
|
|
108
129
|
StatTile(t('home.stat.coverLetters'), counts.cover_letters, 'file', '/documents'),
|
|
109
130
|
StatTile(t('home.stat.interviewPending'), counts.interview_pending, 'mic', '/interview'),
|
|
110
131
|
);
|
|
@@ -195,6 +216,17 @@ function ActionLane(s) {
|
|
|
195
216
|
onClick: () => navigate(`/jobs/${it.job.id}`),
|
|
196
217
|
}));
|
|
197
218
|
}
|
|
219
|
+
// Hiring-schedule stages (면접·인적성·발표) sit right after interview prep and before
|
|
220
|
+
// deadlines: they're the most time-bound "be there on this date" items.
|
|
221
|
+
for (const it of s.upcoming_schedule || []) {
|
|
222
|
+
push(it.job.id, ListRow({
|
|
223
|
+
leading: statusDot('interview'),
|
|
224
|
+
title: it.job.company,
|
|
225
|
+
sub: it.time ? `${it.label} · ${it.time}` : it.label,
|
|
226
|
+
trailing: scheduleDdayBadge(it.d_day),
|
|
227
|
+
onClick: () => navigate(`/jobs/${it.job.id}`),
|
|
228
|
+
}));
|
|
229
|
+
}
|
|
198
230
|
for (const it of s.deadlines || []) {
|
|
199
231
|
push(it.job.id, ListRow({
|
|
200
232
|
leading: statusDot(it.status),
|
|
@@ -146,12 +146,19 @@ export async function render(ctx) {
|
|
|
146
146
|
mount(panel, list.length
|
|
147
147
|
? el('div', { class: 'stack-3' }, ...list.map(QuestionBlock))
|
|
148
148
|
: el('p', { class: 'muted', style: { margin: 0 } }, t('interview.modal.questionsEmptyTab')));
|
|
149
|
-
for (const c of CATEGORIES)
|
|
149
|
+
for (const c of CATEGORIES) {
|
|
150
|
+
tabEls[c].classList.toggle('is-active', c === active);
|
|
151
|
+
tabEls[c].setAttribute('aria-selected', String(c === active));
|
|
152
|
+
}
|
|
150
153
|
}
|
|
151
|
-
|
|
154
|
+
// 키보드 조작 가능 탭(activate = role/tabindex/Enter·Space) + 탭 시맨틱.
|
|
155
|
+
const tabs = el('div', { class: 'tabs', attrs: { role: 'tablist' } }, ...CATEGORIES.map((c) => {
|
|
152
156
|
const n = groups[c].length;
|
|
153
|
-
const tabEl = el('div', {
|
|
154
|
-
|
|
157
|
+
const tabEl = el('div', {
|
|
158
|
+
class: 'tab',
|
|
159
|
+
attrs: { role: 'tab', 'aria-selected': 'false' },
|
|
160
|
+
activate: () => { active = c; draw(); },
|
|
161
|
+
}, n ? `${t('interview.tab.' + c)} (${n})` : t('interview.tab.' + c));
|
|
155
162
|
tabEls[c] = tabEl;
|
|
156
163
|
return tabEl;
|
|
157
164
|
}));
|