careermate 0.0.4 → 0.0.6
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 +11 -7
- package/INSTALL.md +18 -16
- package/README.md +28 -26
- package/THIRD_PARTY_NOTICES.md +2 -3
- package/bin/careermate.mjs +8 -1
- package/dist/career-os/README.md +8 -3
- package/dist/career-os/eop/career-description.md +136 -0
- package/dist/career-os/eop/cover-letter.md +7 -5
- package/dist/career-os/eop/fit-analysis.md +15 -5
- package/dist/career-os/eop/interview-prep.md +209 -21
- package/dist/career-os/eop/job-analysis.md +5 -3
- package/dist/career-os/eop/profile-extraction.md +5 -4
- package/dist/career-os/knowledge/cover-letter.md +4 -4
- package/dist/career-os/knowledge/fit-matching.md +8 -8
- package/dist/career-os/knowledge/interview-behavioral.md +76 -8
- package/dist/career-os/knowledge/interview-technical.md +106 -4
- package/dist/career-os/knowledge/linkedin-profile.md +1 -1
- package/dist/career-os/knowledge/networking-referrals.md +1 -1
- package/dist/career-os/knowledge/offer-evaluation-decision.md +10 -10
- package/dist/career-os/knowledge/portfolio.md +3 -3
- package/dist/career-os/knowledge/verifiers/ats-compat.md +8 -4
- package/dist/career-os/knowledge/verifiers/human-voice.md +2 -2
- package/dist/career-os/knowledge/verifiers/recency-staleness.md +1 -1
- package/dist/career-os/knowledge/verifiers/responsiveness-on-target.md +2 -2
- package/dist/career-os/knowledge/verifiers/truthfulness.md +1 -1
- package/dist/doctor.mjs +1019 -127
- package/dist/mcp.mjs +1036 -126
- package/dist/migrate.mjs +52 -1
- package/dist/public/app.js +42 -16
- package/dist/public/i18n/en/applications.js +23 -0
- package/dist/public/i18n/en/common.js +22 -0
- package/dist/public/i18n/en/documents.js +133 -0
- package/dist/public/i18n/en/home.js +75 -0
- package/dist/public/i18n/en/interview.js +69 -0
- package/dist/public/i18n/en/jobs.js +152 -0
- package/dist/public/i18n/en/nav.js +18 -0
- package/dist/public/i18n/en/profile.js +201 -0
- package/dist/public/i18n/en/settings.js +127 -0
- package/dist/public/i18n/en/status.js +22 -0
- package/dist/public/i18n/en.js +17 -0
- package/dist/public/i18n/ko/applications.js +23 -0
- package/dist/public/i18n/ko/common.js +22 -0
- package/dist/public/i18n/ko/documents.js +133 -0
- package/dist/public/i18n/ko/home.js +63 -0
- package/dist/public/i18n/ko/interview.js +69 -0
- package/dist/public/i18n/ko/jobs.js +152 -0
- package/dist/public/i18n/ko/nav.js +18 -0
- package/dist/public/i18n/ko/profile.js +201 -0
- package/dist/public/i18n/ko/settings.js +127 -0
- package/dist/public/i18n/ko/status.js +23 -0
- package/dist/public/i18n/ko.js +18 -0
- package/dist/public/i18n.js +55 -0
- package/dist/public/index.html +1 -0
- package/dist/public/lib.js +25 -17
- package/dist/public/pages/applications.js +17 -21
- package/dist/public/pages/documents.js +153 -107
- package/dist/public/pages/home.js +88 -53
- package/dist/public/pages/interview.js +105 -51
- package/dist/public/pages/jobs.js +330 -106
- package/dist/public/pages/profile.js +157 -152
- package/dist/public/pages/settings.js +270 -72
- package/dist/public/styles.css +38 -3
- package/dist/web.mjs +859 -103
- package/docs/FAQ.md +26 -5
- package/docs/MCP_TOOLS.md +26 -12
- package/docs/START_WORKFLOW.md +2 -2
- package/docs/SUPPORTED_AI_APPS.md +13 -0
- package/docs/WORKFLOWS.md +80 -23
- package/docs/career-os/README.md +8 -3
- package/docs/career-os/eop/career-description.md +136 -0
- package/docs/career-os/eop/cover-letter.md +7 -5
- package/docs/career-os/eop/fit-analysis.md +15 -5
- package/docs/career-os/eop/interview-prep.md +209 -21
- package/docs/career-os/eop/job-analysis.md +5 -3
- package/docs/career-os/eop/profile-extraction.md +5 -4
- package/docs/career-os/knowledge/cover-letter.md +4 -4
- package/docs/career-os/knowledge/fit-matching.md +8 -8
- package/docs/career-os/knowledge/interview-behavioral.md +76 -8
- package/docs/career-os/knowledge/interview-technical.md +106 -4
- package/docs/career-os/knowledge/linkedin-profile.md +1 -1
- package/docs/career-os/knowledge/networking-referrals.md +1 -1
- package/docs/career-os/knowledge/offer-evaluation-decision.md +10 -10
- package/docs/career-os/knowledge/portfolio.md +3 -3
- package/docs/career-os/knowledge/verifiers/ats-compat.md +8 -4
- package/docs/career-os/knowledge/verifiers/human-voice.md +2 -2
- package/docs/career-os/knowledge/verifiers/recency-staleness.md +1 -1
- package/docs/career-os/knowledge/verifiers/responsiveness-on-target.md +2 -2
- package/docs/career-os/knowledge/verifiers/truthfulness.md +1 -1
- package/package.json +2 -2
- package/packages/core/src/onboarding.ts +2 -1
- package/packages/core/src/services.ts +138 -21
- package/packages/core/src/summary.ts +59 -1
- package/packages/core/src/verify/lint.ts +4 -0
- package/packages/db/src/index.ts +1 -0
- package/packages/db/src/repositories.ts +0 -0
- package/packages/db/src/schema.ts +17 -0
- package/packages/exporters/src/index.ts +28 -17
- package/packages/knowledge/src/index.ts +46 -7
- package/packages/mcp-tools/src/bridge.ts +194 -13
- package/packages/mcp-tools/src/tools.ts +390 -38
- package/packages/mcp-tools/src/update.ts +31 -16
- package/packages/parsers/src/documents.ts +12 -1
- package/packages/prompts/src/humanize.ts +46 -0
- package/packages/prompts/src/index.ts +5 -1
- package/packages/prompts/src/install.ts +4 -4
- package/packages/prompts/src/onboarding.ts +4 -4
- package/packages/prompts/src/system.ts +17 -8
- package/packages/shared/src/enums.ts +14 -4
- package/packages/shared/src/schemas.ts +41 -0
- package/packages/workflows/src/definitions.ts +47 -10
- package/scripts/init.ts +284 -13
- package/dist/site/analytics.js +0 -97
- package/dist/site/favicon.svg +0 -1
- package/dist/site/fonts/PretendardVariable.woff2 +0 -0
- package/dist/site/icon-180.png +0 -0
- package/dist/site/icon-512.png +0 -0
- package/dist/site/index.html +0 -565
- package/dist/site/landing.js +0 -54
- package/dist/site/llms-install.txt +0 -218
- package/dist/site/robots.txt +0 -4
- package/dist/site/sitemap.xml +0 -21
- package/dist/site/start.html +0 -353
- package/dist/site/style.css +0 -559
- package/dist/site/vercel.json +0 -20
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
openModal, closeModal, confirmDialog, toastOk, toastError,
|
|
9
9
|
fmtDate, scoreClass, mount, meta, linesToArray, csvToArray,
|
|
10
10
|
} from '/lib.js';
|
|
11
|
+
import { t } from '/i18n.js';
|
|
11
12
|
|
|
12
13
|
export async function render(ctx) {
|
|
13
14
|
if (ctx.params[0]) return renderDetail(ctx, ctx.params[0]);
|
|
@@ -24,16 +25,16 @@ async function renderList(ctx) {
|
|
|
24
25
|
ctx.setActions([]); // 단일 1차 액션은 빈 상태에만 둔다
|
|
25
26
|
wrap.append(EmptyState({
|
|
26
27
|
iconName: 'briefcase',
|
|
27
|
-
title: '
|
|
28
|
-
body: '
|
|
29
|
-
action: Btn('
|
|
28
|
+
title: t('jobs.list.empty.title'),
|
|
29
|
+
body: t('jobs.list.empty.body'),
|
|
30
|
+
action: Btn(t('jobs.list.addJob'), { icon: 'plus', variant: 'primary', onClick: () => openJobModal(ctx) }),
|
|
30
31
|
}));
|
|
31
32
|
mount(ctx.view, wrap);
|
|
32
33
|
return;
|
|
33
34
|
}
|
|
34
35
|
|
|
35
|
-
const addBtn = Btn('
|
|
36
|
-
const search = Input({ type: 'search', placeholder: '
|
|
36
|
+
const addBtn = Btn(t('jobs.list.addJob'), { icon: 'plus', variant: 'primary', sm: true, onClick: () => openJobModal(ctx) });
|
|
37
|
+
const search = Input({ type: 'search', placeholder: t('jobs.list.searchPlaceholder'), attrs: { 'aria-label': t('jobs.list.searchAria') } });
|
|
37
38
|
search.classList.add('input--inline');
|
|
38
39
|
ctx.setActions([search, addBtn]);
|
|
39
40
|
|
|
@@ -45,30 +46,30 @@ async function renderList(ctx) {
|
|
|
45
46
|
el('a', { class: 'cell-link strong', href: `#/jobs/${j.id}` }, j.company),
|
|
46
47
|
el('div', { class: 'muted text-sm' }, j.position)),
|
|
47
48
|
el('td', {},
|
|
48
|
-
el('div', {}, j.location || el('span', { class: 'muted' }, '
|
|
49
|
-
el('div', { class: 'muted text-sm' }, j.deadline ?
|
|
49
|
+
el('div', {}, j.location || el('span', { class: 'muted' }, t('jobs.list.locationUnset'))),
|
|
50
|
+
el('div', { class: 'muted text-sm' }, j.deadline ? t('jobs.list.deadline', { date: fmtDate(j.deadline) }) : t('jobs.list.noDeadline'))),
|
|
50
51
|
el('td', { style: { textAlign: 'right' } },
|
|
51
52
|
j.fit_score != null
|
|
52
|
-
? el('span', { class: `strong tnum ${scoreClass(j.fit_score)}` },
|
|
53
|
+
? el('span', { class: `strong tnum ${scoreClass(j.fit_score)}` }, t('jobs.list.score', { score: j.fit_score }))
|
|
53
54
|
: el('span', { class: 'muted' }, '—')),
|
|
54
|
-
el('td', {}, Badge(j.status, j.
|
|
55
|
+
el('td', {}, Badge(j.status, t('status.' + j.status)))),
|
|
55
56
|
text: `${j.company} ${j.position} ${j.location || ''} ${(j.keywords || []).join(' ')}`.toLowerCase(),
|
|
56
57
|
}));
|
|
57
58
|
|
|
58
59
|
const tbody = el('tbody', {}, ...rowData.map((r) => r.tr));
|
|
59
60
|
const noResult = el('tr', { class: 'hide' },
|
|
60
|
-
el('td', { attrs: { colspan: '4' }, class: 'muted', style: { textAlign: 'center', padding: '24px 16px' } }, '
|
|
61
|
+
el('td', { attrs: { colspan: '4' }, class: 'muted', style: { textAlign: 'center', padding: '24px 16px' } }, t('jobs.list.noResult')));
|
|
61
62
|
tbody.append(noResult);
|
|
62
63
|
|
|
63
64
|
wrap.append(Card({
|
|
64
|
-
title: '
|
|
65
|
-
sub:
|
|
65
|
+
title: t('jobs.list.cardTitle'),
|
|
66
|
+
sub: t('jobs.list.cardSub', { count: jobs.length }),
|
|
66
67
|
body: el('div', { class: 'table-scroll' }, el('table', { class: 'table table--jobs' },
|
|
67
68
|
el('thead', {}, el('tr', {},
|
|
68
|
-
el('th', {}, '
|
|
69
|
-
el('th', {}, '
|
|
70
|
-
el('th', { style: { textAlign: 'right' } }, '
|
|
71
|
-
el('th', {}, '
|
|
69
|
+
el('th', {}, t('jobs.list.col.companyRole')),
|
|
70
|
+
el('th', {}, t('jobs.list.col.locationDeadline')),
|
|
71
|
+
el('th', { style: { textAlign: 'right' } }, t('jobs.list.col.fit')),
|
|
72
|
+
el('th', {}, t('jobs.list.col.status')))),
|
|
72
73
|
tbody)),
|
|
73
74
|
}));
|
|
74
75
|
|
|
@@ -91,35 +92,35 @@ async function renderList(ctx) {
|
|
|
91
92
|
*/
|
|
92
93
|
function jobForm(ctx, { job, onSaved }) {
|
|
93
94
|
const v = job || {};
|
|
94
|
-
const company = Input({ value: v.company || '', placeholder: '
|
|
95
|
-
const position = Input({ value: v.position || '', placeholder: '
|
|
96
|
-
const url = Input({ value: v.url || '', placeholder: '
|
|
97
|
-
const location = Input({ value: v.location || '', placeholder: '
|
|
98
|
-
const employment = Input({ value: v.employment_type || '', placeholder: '
|
|
99
|
-
const deadline = Input({ value: v.deadline || '', placeholder: '
|
|
100
|
-
const source = Input({ value: v.source || '', placeholder: '
|
|
95
|
+
const company = Input({ value: v.company || '', placeholder: t('jobs.form.companyPlaceholder'), attrs: { required: 'required' } });
|
|
96
|
+
const position = Input({ value: v.position || '', placeholder: t('jobs.form.positionPlaceholder'), attrs: { required: 'required' } });
|
|
97
|
+
const url = Input({ value: v.url || '', placeholder: t('jobs.form.urlPlaceholder'), type: 'url' });
|
|
98
|
+
const location = Input({ value: v.location || '', placeholder: t('jobs.form.locationPlaceholder') });
|
|
99
|
+
const employment = Input({ value: v.employment_type || '', placeholder: t('jobs.form.employmentPlaceholder') });
|
|
100
|
+
const deadline = Input({ value: v.deadline || '', placeholder: t('jobs.form.deadlinePlaceholder') });
|
|
101
|
+
const source = Input({ value: v.source || '', placeholder: t('jobs.form.sourcePlaceholder') });
|
|
101
102
|
const description = Textarea({
|
|
102
|
-
value: v.description || '', placeholder: '
|
|
103
|
+
value: v.description || '', placeholder: t('jobs.form.descriptionPlaceholder'),
|
|
103
104
|
style: { minHeight: '160px' },
|
|
104
105
|
});
|
|
105
|
-
const keywords = Input({ value: (v.keywords || []).join(', '), placeholder: '
|
|
106
|
+
const keywords = Input({ value: (v.keywords || []).join(', '), placeholder: t('jobs.form.keywordsPlaceholder') });
|
|
106
107
|
const requirements = Textarea({
|
|
107
|
-
value: (v.requirements || []).join('\n'), placeholder: '
|
|
108
|
+
value: (v.requirements || []).join('\n'), placeholder: t('jobs.form.requirementsPlaceholder'),
|
|
108
109
|
style: { minHeight: '90px' },
|
|
109
110
|
});
|
|
110
111
|
|
|
111
112
|
// "붙여넣은 텍스트 정리" — server-side clean + best-effort field extraction.
|
|
112
113
|
// Deterministic text parsing — NOT AI (CareerMate has no LLM). Use a neutral icon and
|
|
113
114
|
// show "정리 중…" so a slow parse of a long posting doesn't look frozen.
|
|
114
|
-
const tidyBtn = Btn('
|
|
115
|
+
const tidyBtn = Btn(t('jobs.form.tidy'), {
|
|
115
116
|
icon: 'edit', sm: true, variant: 'ghost',
|
|
116
117
|
onClick: async () => {
|
|
117
118
|
const raw = description.value.trim();
|
|
118
|
-
if (!raw) { toastError('
|
|
119
|
+
if (!raw) { toastError(t('jobs.form.tidyNeedsText')); return; }
|
|
119
120
|
const labelEl = tidyBtn.querySelector('span');
|
|
120
121
|
const origLabel = labelEl ? labelEl.textContent : '';
|
|
121
122
|
tidyBtn.disabled = true;
|
|
122
|
-
if (labelEl) labelEl.textContent = '
|
|
123
|
+
if (labelEl) labelEl.textContent = t('jobs.form.tidyBusy');
|
|
123
124
|
try {
|
|
124
125
|
const r = await post('/api/parse/job', { raw });
|
|
125
126
|
if (r.text) description.value = r.text;
|
|
@@ -131,7 +132,7 @@ function jobForm(ctx, { job, onSaved }) {
|
|
|
131
132
|
const merged = [...new Set([...existing, ...r.keywords])];
|
|
132
133
|
keywords.value = merged.join(', ');
|
|
133
134
|
}
|
|
134
|
-
toastOk('
|
|
135
|
+
toastOk(t('jobs.form.tidyDone'));
|
|
135
136
|
} catch (e) { toastError(e); }
|
|
136
137
|
finally { tidyBtn.disabled = false; if (labelEl) labelEl.textContent = origLabel; }
|
|
137
138
|
},
|
|
@@ -139,20 +140,20 @@ function jobForm(ctx, { job, onSaved }) {
|
|
|
139
140
|
|
|
140
141
|
const form = el('form', { class: 'stack-3' },
|
|
141
142
|
el('div', { class: 'grid grid--2' },
|
|
142
|
-
Field('
|
|
143
|
-
Field('
|
|
144
|
-
Field('
|
|
143
|
+
Field(t('jobs.form.field.company'), company),
|
|
144
|
+
Field(t('jobs.form.field.position'), position)),
|
|
145
|
+
Field(t('jobs.form.field.url'), url),
|
|
145
146
|
el('div', { class: 'grid grid--3' },
|
|
146
|
-
Field('
|
|
147
|
-
Field('
|
|
148
|
-
Field('
|
|
149
|
-
Field('
|
|
147
|
+
Field(t('jobs.form.field.location'), location),
|
|
148
|
+
Field(t('jobs.form.field.employment'), employment),
|
|
149
|
+
Field(t('jobs.form.field.deadline'), deadline, t('jobs.form.field.deadlineHint'))),
|
|
150
|
+
Field(t('jobs.form.field.source'), source),
|
|
150
151
|
Field(
|
|
151
|
-
el('div', { class: 'flex between center' }, el('span', {}, '
|
|
152
|
+
el('div', { class: 'flex between center' }, el('span', {}, t('jobs.form.field.description')), tidyBtn),
|
|
152
153
|
description,
|
|
153
|
-
'
|
|
154
|
-
Field('
|
|
155
|
-
Field('
|
|
154
|
+
t('jobs.form.field.descriptionHint')),
|
|
155
|
+
Field(t('jobs.form.field.keywords'), keywords, t('jobs.form.field.keywordsHint')),
|
|
156
|
+
Field(t('jobs.form.field.requirements'), requirements, t('jobs.form.field.requirementsHint')),
|
|
156
157
|
);
|
|
157
158
|
|
|
158
159
|
// Throws on validation/API failure so the SubmitBtn shows it inline.
|
|
@@ -169,11 +170,11 @@ function jobForm(ctx, { job, onSaved }) {
|
|
|
169
170
|
keywords: csvToArray(keywords.value),
|
|
170
171
|
requirements: linesToArray(requirements.value),
|
|
171
172
|
};
|
|
172
|
-
if (!body.company || !body.position) throw new Error('
|
|
173
|
+
if (!body.company || !body.position) throw new Error(t('jobs.form.validationRequired'));
|
|
173
174
|
const res = job
|
|
174
175
|
? await put(`/api/jobs/${job.id}`, body)
|
|
175
176
|
: await post('/api/jobs', body);
|
|
176
|
-
toastOk(job ? '
|
|
177
|
+
toastOk(job ? t('jobs.form.savedEdit') : t('jobs.form.savedNew'));
|
|
177
178
|
closeModal();
|
|
178
179
|
await onSaved(res);
|
|
179
180
|
}
|
|
@@ -193,14 +194,14 @@ function openJobModal(ctx) {
|
|
|
193
194
|
navigate(`/jobs/${res.job.id}`);
|
|
194
195
|
},
|
|
195
196
|
});
|
|
196
|
-
const saveBtn = SubmitBtn('
|
|
197
|
+
const saveBtn = SubmitBtn(t('jobs.form.save'), built.submit);
|
|
197
198
|
built.bindSubmit(saveBtn);
|
|
198
199
|
openModal({
|
|
199
|
-
title: '
|
|
200
|
+
title: t('jobs.modal.addTitle'),
|
|
200
201
|
size: 'lg',
|
|
201
202
|
body: built.form,
|
|
202
203
|
footer: (close) => [
|
|
203
|
-
Btn('
|
|
204
|
+
Btn(t('jobs.form.cancel'), { onClick: close }),
|
|
204
205
|
saveBtn,
|
|
205
206
|
],
|
|
206
207
|
});
|
|
@@ -211,14 +212,14 @@ function openEditModal(ctx, job, remount) {
|
|
|
211
212
|
job,
|
|
212
213
|
onSaved: async () => { await ctx.refreshNav(); await remount(); },
|
|
213
214
|
});
|
|
214
|
-
const saveBtn = SubmitBtn('
|
|
215
|
+
const saveBtn = SubmitBtn(t('jobs.form.save'), built.submit);
|
|
215
216
|
built.bindSubmit(saveBtn);
|
|
216
217
|
openModal({
|
|
217
|
-
title: '
|
|
218
|
+
title: t('jobs.modal.editTitle'),
|
|
218
219
|
size: 'lg',
|
|
219
220
|
body: built.form,
|
|
220
221
|
footer: (close) => [
|
|
221
|
-
Btn('
|
|
222
|
+
Btn(t('jobs.form.cancel'), { onClick: close }),
|
|
222
223
|
saveBtn,
|
|
223
224
|
],
|
|
224
225
|
});
|
|
@@ -231,13 +232,13 @@ async function renderDetail(ctx, jobId) {
|
|
|
231
232
|
try {
|
|
232
233
|
({ job } = await get(`/api/jobs/${jobId}`));
|
|
233
234
|
} catch (err) {
|
|
234
|
-
ctx.setTitle('
|
|
235
|
+
ctx.setTitle(t('jobs.detail.notFoundTitle'));
|
|
235
236
|
const notFound = el('div', { class: 'stack-4' },
|
|
236
237
|
EmptyState({
|
|
237
238
|
iconName: 'briefcase',
|
|
238
|
-
title: '
|
|
239
|
-
body: err instanceof Error ? err.message : '
|
|
240
|
-
action: Btn('
|
|
239
|
+
title: t('jobs.detail.notFoundCardTitle'),
|
|
240
|
+
body: err instanceof Error ? err.message : t('jobs.detail.notFoundBody'),
|
|
241
|
+
action: Btn(t('jobs.detail.backToList'), { icon: 'chevronRight', variant: 'primary', onClick: () => navigate('/jobs') }),
|
|
241
242
|
}));
|
|
242
243
|
mount(ctx.view, notFound);
|
|
243
244
|
ctx.setActions(null);
|
|
@@ -247,21 +248,35 @@ async function renderDetail(ctx, jobId) {
|
|
|
247
248
|
const remount = () => renderDetail(ctx, jobId);
|
|
248
249
|
const m = await meta();
|
|
249
250
|
|
|
250
|
-
ctx.setTitle(
|
|
251
|
+
ctx.setTitle(t('jobs.detail.title', { company: job.company, position: job.position }));
|
|
251
252
|
|
|
252
253
|
// --- Header actions (also mirrored into the topbar) -----------------------
|
|
254
|
+
async function changeStatus(status, extra = {}) {
|
|
255
|
+
const res = await put(`/api/applications/${jobId}/status`, { status, ...extra });
|
|
256
|
+
toastOk(t('jobs.detail.statusChanged'));
|
|
257
|
+
if (res && res.hint) toastOk(res.hint);
|
|
258
|
+
await ctx.refreshNav();
|
|
259
|
+
await remount();
|
|
260
|
+
}
|
|
261
|
+
|
|
253
262
|
const statusSelect = Select(
|
|
254
|
-
m.statuses.map((s) => ({ value: s.value, label: s.
|
|
263
|
+
m.statuses.map((s) => ({ value: s.value, label: t('status.' + s.value), selected: s.value === job.status })),
|
|
255
264
|
{
|
|
265
|
+
attrs: { 'aria-label': t('jobs.detail.statusLabel') },
|
|
256
266
|
onChange: async (e) => {
|
|
257
267
|
const status = e.target.value;
|
|
258
268
|
if (status === job.status) return;
|
|
269
|
+
if (status === 'applied') {
|
|
270
|
+
e.target.value = job.status;
|
|
271
|
+
try {
|
|
272
|
+
await openAppliedModal({ job, metaInfo: m, changeStatus });
|
|
273
|
+
} catch (err) {
|
|
274
|
+
toastError(err);
|
|
275
|
+
}
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
259
278
|
try {
|
|
260
|
-
|
|
261
|
-
toastOk('상태를 변경했어요.');
|
|
262
|
-
if (res && res.hint) toastOk(res.hint);
|
|
263
|
-
await ctx.refreshNav();
|
|
264
|
-
await remount();
|
|
279
|
+
await changeStatus(status);
|
|
265
280
|
} catch (err) {
|
|
266
281
|
toastError(err);
|
|
267
282
|
// Stale tab: the job was removed since this page loaded. Re-render so the
|
|
@@ -274,19 +289,19 @@ async function renderDetail(ctx, jobId) {
|
|
|
274
289
|
);
|
|
275
290
|
statusSelect.classList.add('select--sm');
|
|
276
291
|
|
|
277
|
-
const editBtn = Btn('
|
|
278
|
-
const delBtn = Btn('
|
|
292
|
+
const editBtn = Btn(t('jobs.detail.edit'), { icon: 'edit', sm: true, variant: 'ghost', onClick: () => openEditModal(ctx, job, remount) });
|
|
293
|
+
const delBtn = Btn(t('jobs.detail.delete'), {
|
|
279
294
|
icon: 'trash', sm: true, variant: 'danger',
|
|
280
295
|
onClick: async () => {
|
|
281
296
|
const ok = await confirmDialog({
|
|
282
|
-
title: '
|
|
283
|
-
message:
|
|
284
|
-
confirmLabel: '
|
|
297
|
+
title: t('jobs.detail.deleteTitle'),
|
|
298
|
+
message: t('jobs.detail.deleteMessage', { company: job.company, position: job.position }),
|
|
299
|
+
confirmLabel: t('jobs.detail.deleteConfirm'), danger: true,
|
|
285
300
|
});
|
|
286
301
|
if (!ok) return;
|
|
287
302
|
try {
|
|
288
303
|
await del(`/api/jobs/${jobId}`);
|
|
289
|
-
toastOk('
|
|
304
|
+
toastOk(t('jobs.detail.deleted'));
|
|
290
305
|
await ctx.refreshNav();
|
|
291
306
|
navigate('/jobs');
|
|
292
307
|
} catch (err) { toastError(err); }
|
|
@@ -294,15 +309,15 @@ async function renderDetail(ctx, jobId) {
|
|
|
294
309
|
});
|
|
295
310
|
|
|
296
311
|
ctx.setActions([
|
|
297
|
-
Btn('
|
|
312
|
+
Btn(t('jobs.detail.list'), { icon: 'chevronRight', sm: true, variant: 'ghost', onClick: () => navigate('/jobs') }),
|
|
298
313
|
editBtn,
|
|
299
314
|
]);
|
|
300
315
|
|
|
301
316
|
// --- Page head ------------------------------------------------------------
|
|
302
317
|
// 수정은 상단바(ctx.setActions)에만 둔다 — 여기엔 상태 변경·원문·삭제만.
|
|
303
318
|
const headActions = el('div', { class: 'flex gap-2 center wrap' },
|
|
304
|
-
|
|
305
|
-
job.url ? el('a', { class: 'btn btn--ghost btn--sm', href: job.url, attrs: { target: '_blank', rel: 'noopener noreferrer' } }, icon('external'), el('span', {}, '
|
|
319
|
+
statusSelect,
|
|
320
|
+
job.url ? el('a', { class: 'btn btn--ghost btn--sm', href: job.url, attrs: { target: '_blank', rel: 'noopener noreferrer' } }, icon('external'), el('span', {}, t('jobs.detail.openPosting'))) : null,
|
|
306
321
|
delBtn,
|
|
307
322
|
);
|
|
308
323
|
|
|
@@ -310,7 +325,7 @@ async function renderDetail(ctx, jobId) {
|
|
|
310
325
|
el('div', { class: 'page-head__text' },
|
|
311
326
|
el('div', { class: 'flex gap-3 center wrap' },
|
|
312
327
|
el('h1', {}, job.company),
|
|
313
|
-
Badge(job.status, job.
|
|
328
|
+
Badge(job.status, t('status.' + job.status))),
|
|
314
329
|
el('p', {}, job.position)),
|
|
315
330
|
el('div', { class: 'page-head__actions' }, headActions),
|
|
316
331
|
);
|
|
@@ -326,6 +341,7 @@ async function renderDetail(ctx, jobId) {
|
|
|
326
341
|
InterviewCard(job)),
|
|
327
342
|
PostingCard(job),
|
|
328
343
|
FitCard(job.fit),
|
|
344
|
+
TimelineCard(job.timeline),
|
|
329
345
|
RelatedCard(job.related),
|
|
330
346
|
);
|
|
331
347
|
mount(ctx.view, wrap);
|
|
@@ -337,21 +353,21 @@ function PostingCard(job) {
|
|
|
337
353
|
const body = [];
|
|
338
354
|
|
|
339
355
|
body.push(el('dl', { class: 'kv' },
|
|
340
|
-
el('dt', {}, '
|
|
341
|
-
el('dt', {}, '
|
|
342
|
-
el('dt', {}, '
|
|
343
|
-
el('dt', {}, '
|
|
356
|
+
el('dt', {}, t('jobs.posting.location')), el('dd', {}, job.location || el('span', { class: 'muted' }, t('jobs.posting.unset'))),
|
|
357
|
+
el('dt', {}, t('jobs.posting.employment')), el('dd', {}, job.employment_type || el('span', { class: 'muted' }, t('jobs.posting.unset'))),
|
|
358
|
+
el('dt', {}, t('jobs.posting.deadline')), el('dd', {}, job.deadline ? fmtDate(job.deadline) : el('span', { class: 'muted' }, t('jobs.posting.none'))),
|
|
359
|
+
el('dt', {}, t('jobs.posting.source')), el('dd', {}, job.source || el('span', { class: 'muted' }, t('jobs.posting.sourceManual'))),
|
|
344
360
|
));
|
|
345
361
|
|
|
346
362
|
if (job.keywords && job.keywords.length) {
|
|
347
363
|
body.push(el('div', { class: 'mt-3' },
|
|
348
|
-
el('div', { class: 'muted text-sm mb-2' }, '
|
|
364
|
+
el('div', { class: 'muted text-sm mb-2' }, t('jobs.posting.keywords')),
|
|
349
365
|
Chips(job.keywords, { accent: true })));
|
|
350
366
|
}
|
|
351
367
|
|
|
352
368
|
if (job.requirements && job.requirements.length) {
|
|
353
369
|
body.push(el('div', { class: 'mt-3' },
|
|
354
|
-
el('div', { class: 'muted text-sm mb-2' }, '
|
|
370
|
+
el('div', { class: 'muted text-sm mb-2' }, t('jobs.posting.requirements')),
|
|
355
371
|
el('ul', { class: 'stack-2', style: { margin: 0, paddingLeft: '18px' } },
|
|
356
372
|
...job.requirements.map((r) => el('li', {}, r)))));
|
|
357
373
|
}
|
|
@@ -361,18 +377,18 @@ function PostingCard(job) {
|
|
|
361
377
|
body.push(el('details', { class: 'disclosure mt-3' },
|
|
362
378
|
el('summary', { class: 'disclosure__summary' },
|
|
363
379
|
icon('chevronRight', 'disclosure__chevron'),
|
|
364
|
-
el('span', {}, '
|
|
380
|
+
el('span', {}, t('jobs.posting.rawText'))),
|
|
365
381
|
el('div', { class: 'doc-preview mt-2' }, job.description)));
|
|
366
382
|
}
|
|
367
383
|
|
|
368
|
-
return Card({ title: '
|
|
384
|
+
return Card({ title: t('jobs.posting.title'), body });
|
|
369
385
|
}
|
|
370
386
|
|
|
371
387
|
function FitCard(fit) {
|
|
372
388
|
if (!fit) {
|
|
373
389
|
return Card({
|
|
374
|
-
title: '
|
|
375
|
-
body: el('p', { class: 'muted', style: { margin: 0 } }, '
|
|
390
|
+
title: t('jobs.fit.title'),
|
|
391
|
+
body: el('p', { class: 'muted', style: { margin: 0 } }, t('jobs.fit.emptyBody')),
|
|
376
392
|
});
|
|
377
393
|
}
|
|
378
394
|
|
|
@@ -382,7 +398,7 @@ function FitCard(fit) {
|
|
|
382
398
|
fit.score != null
|
|
383
399
|
? el('div', { class: `tnum ${scoreClass(fit.score)}`, style: { fontSize: '40px', fontWeight: '700', lineHeight: '1' } }, String(fit.score))
|
|
384
400
|
: el('div', { class: 'muted', style: { fontSize: '28px' } }, '—'),
|
|
385
|
-
el('div', { class: 'muted text-sm' }, fit.score != null ? '
|
|
401
|
+
el('div', { class: 'muted text-sm' }, fit.score != null ? t('jobs.fit.overall') : t('jobs.fit.notScored')),
|
|
386
402
|
));
|
|
387
403
|
|
|
388
404
|
if (fit.summary) {
|
|
@@ -391,7 +407,7 @@ function FitCard(fit) {
|
|
|
391
407
|
|
|
392
408
|
if (fit.strengths && fit.strengths.length) {
|
|
393
409
|
body.push(el('div', {},
|
|
394
|
-
el('div', { class: 'muted text-sm mb-2' }, '
|
|
410
|
+
el('div', { class: 'muted text-sm mb-2' }, t('jobs.fit.strengths')),
|
|
395
411
|
el('div', { class: 'stack-2' },
|
|
396
412
|
...fit.strengths.map((s) => el('div', { class: 'flex gap-2', style: { alignItems: 'flex-start' } },
|
|
397
413
|
icon('check', 'score-strong'),
|
|
@@ -400,55 +416,55 @@ function FitCard(fit) {
|
|
|
400
416
|
|
|
401
417
|
if (fit.gaps && fit.gaps.length) {
|
|
402
418
|
body.push(el('div', {},
|
|
403
|
-
el('div', { class: 'muted text-sm mb-2' }, '
|
|
419
|
+
el('div', { class: 'muted text-sm mb-2' }, t('jobs.fit.gaps')),
|
|
404
420
|
el('ul', { class: 'stack-2', style: { margin: 0, paddingLeft: '18px' } },
|
|
405
421
|
...fit.gaps.map((g) => el('li', { class: 'text-secondary' }, g)))));
|
|
406
422
|
}
|
|
407
423
|
|
|
408
424
|
if (fit.matched_keywords && fit.matched_keywords.length) {
|
|
409
425
|
body.push(el('div', {},
|
|
410
|
-
el('div', { class: 'muted text-sm mb-2' }, '
|
|
426
|
+
el('div', { class: 'muted text-sm mb-2' }, t('jobs.fit.matchedKeywords')),
|
|
411
427
|
Chips(fit.matched_keywords, { accent: true })));
|
|
412
428
|
}
|
|
413
429
|
if (fit.missing_keywords && fit.missing_keywords.length) {
|
|
414
430
|
body.push(el('div', {},
|
|
415
|
-
el('div', { class: 'muted text-sm mb-2' }, '
|
|
431
|
+
el('div', { class: 'muted text-sm mb-2' }, t('jobs.fit.missingKeywords')),
|
|
416
432
|
Chips(fit.missing_keywords)));
|
|
417
433
|
}
|
|
418
434
|
|
|
419
435
|
if (fit.recommendations && fit.recommendations.length) {
|
|
420
436
|
body.push(el('div', {},
|
|
421
|
-
el('div', { class: 'muted text-sm mb-2' }, '
|
|
437
|
+
el('div', { class: 'muted text-sm mb-2' }, t('jobs.fit.recommendations')),
|
|
422
438
|
el('ul', { class: 'stack-2', style: { margin: 0, paddingLeft: '18px' } },
|
|
423
439
|
...fit.recommendations.map((r) => el('li', { class: 'text-secondary' }, r)))));
|
|
424
440
|
}
|
|
425
441
|
|
|
426
|
-
return Card({ title: '
|
|
442
|
+
return Card({ title: t('jobs.fit.title'), body: el('div', { class: 'stack-3' }, ...body) });
|
|
427
443
|
}
|
|
428
444
|
|
|
429
445
|
function CoverLettersCard(coverLetters) {
|
|
430
446
|
const list = coverLetters || [];
|
|
431
447
|
if (!list.length) {
|
|
432
448
|
return Card({
|
|
433
|
-
title: '
|
|
449
|
+
title: t('jobs.cover.title'),
|
|
434
450
|
body: el('p', { class: 'muted', style: { margin: 0, lineHeight: '1.6' } },
|
|
435
|
-
'
|
|
451
|
+
t('jobs.cover.empty')),
|
|
436
452
|
});
|
|
437
453
|
}
|
|
438
454
|
return Card({
|
|
439
|
-
title: '
|
|
440
|
-
sub:
|
|
455
|
+
title: t('jobs.cover.title'),
|
|
456
|
+
sub: t('jobs.cover.cardSub', { count: list.length }),
|
|
441
457
|
body: el('div', { class: 'stack-2' },
|
|
442
458
|
...list.map((cl) => el('div', {
|
|
443
459
|
class: 'flex between center is-clickable',
|
|
444
460
|
style: { padding: '8px 0', cursor: 'pointer' },
|
|
445
|
-
activate: () => navigate(
|
|
461
|
+
activate: () => navigate(`/documents/cover/${cl.id}`),
|
|
446
462
|
},
|
|
447
463
|
el('div', { class: 'flex gap-2 center' },
|
|
448
464
|
icon('file', 'muted'),
|
|
449
465
|
el('div', {},
|
|
450
466
|
el('div', { class: 'strong' }, cl.title),
|
|
451
|
-
el('div', { class: 'muted text-sm' },
|
|
467
|
+
el('div', { class: 'muted text-sm' }, t('jobs.cover.versions', { count: cl.version_count ?? 1 })))),
|
|
452
468
|
icon('chevronRight', 'muted')))),
|
|
453
469
|
});
|
|
454
470
|
}
|
|
@@ -458,34 +474,242 @@ function InterviewCard(job) {
|
|
|
458
474
|
if (interview) {
|
|
459
475
|
const count = (interview.questions || []).length;
|
|
460
476
|
return Card({
|
|
461
|
-
title: '
|
|
462
|
-
actions: Btn('
|
|
477
|
+
title: t('jobs.interview.title'),
|
|
478
|
+
actions: Btn(t('jobs.interview.viewPrep'), { sm: true, variant: 'ghost', onClick: () => navigate('/interview') }),
|
|
463
479
|
body: el('div', { class: 'stack-2' },
|
|
464
480
|
el('div', { class: 'flex gap-2 center' },
|
|
465
481
|
icon('mic', 'muted'),
|
|
466
|
-
el('span', { class: 'text-secondary' }, count ?
|
|
482
|
+
el('span', { class: 'text-secondary' }, count ? t('jobs.interview.questions', { count }) : t('jobs.interview.saved'))),
|
|
467
483
|
interview.self_introduction
|
|
468
|
-
? el('div', { class: 'muted text-sm' }, '
|
|
484
|
+
? el('div', { class: 'muted text-sm' }, t('jobs.interview.introSaved'))
|
|
469
485
|
: null),
|
|
470
486
|
});
|
|
471
487
|
}
|
|
472
488
|
|
|
473
|
-
const eligible = ['document_passed', 'interview', 'final_passed'].includes(job.status);
|
|
474
489
|
return Card({
|
|
475
|
-
title: '
|
|
490
|
+
title: t('jobs.interview.title'),
|
|
476
491
|
body: el('p', { class: 'muted', style: { margin: 0, lineHeight: '1.6' } },
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
492
|
+
t('jobs.interview.empty')),
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
async function openAppliedModal({ job, metaInfo, changeStatus }) {
|
|
497
|
+
const [{ cover_letters: allCoverLetters }, { documents }] = await Promise.all([
|
|
498
|
+
get('/api/cover-letters'),
|
|
499
|
+
get('/api/documents'),
|
|
500
|
+
]);
|
|
501
|
+
const jobCoverIds = new Set((job.cover_letters || []).map((cl) => cl.id));
|
|
502
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
503
|
+
const submittedAt = Input({ type: 'date', value: job.application?.applied_at?.slice(0, 10) || today });
|
|
504
|
+
const channel = Input({ placeholder: t('jobs.applied.channelPlaceholder') });
|
|
505
|
+
const note = Textarea({ placeholder: t('jobs.applied.notePlaceholder'), style: { minHeight: '76px' } });
|
|
506
|
+
const coverList = el('div', {});
|
|
507
|
+
const docList = el('div', { class: 'pick-list' });
|
|
508
|
+
const selectedDocIds = new Set(job.application?.resume_id ? [job.application.resume_id] : []);
|
|
509
|
+
let showAllCovers = false;
|
|
510
|
+
let selectedCoverId =
|
|
511
|
+
job.application?.cover_letter_id ||
|
|
512
|
+
(job.cover_letters || [])[0]?.id ||
|
|
513
|
+
'';
|
|
514
|
+
|
|
515
|
+
function pickRow({ input, title, sub }) {
|
|
516
|
+
return el('label', { class: 'pick-row' },
|
|
517
|
+
input,
|
|
518
|
+
el('span', { class: 'pick-row__main' },
|
|
519
|
+
el('span', { class: 'pick-row__title' }, title),
|
|
520
|
+
sub ? el('span', { class: 'pick-row__sub' }, sub) : null));
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function drawCoverList() {
|
|
524
|
+
const scoped = (allCoverLetters || []).filter((cl) => jobCoverIds.has(cl.id));
|
|
525
|
+
const list = showAllCovers ? (allCoverLetters || []) : scoped;
|
|
526
|
+
const rows = [
|
|
527
|
+
pickRow({
|
|
528
|
+
input: el('input', {
|
|
529
|
+
type: 'radio',
|
|
530
|
+
name: 'submission-cover',
|
|
531
|
+
checked: !selectedCoverId,
|
|
532
|
+
onChange: () => { selectedCoverId = ''; drawCoverList(); },
|
|
533
|
+
}),
|
|
534
|
+
title: t('jobs.applied.coverNone'),
|
|
535
|
+
sub: t('jobs.applied.coverNoneSub'),
|
|
536
|
+
}),
|
|
537
|
+
...list.map((cl) => pickRow({
|
|
538
|
+
input: el('input', {
|
|
539
|
+
type: 'radio',
|
|
540
|
+
name: 'submission-cover',
|
|
541
|
+
checked: selectedCoverId === cl.id,
|
|
542
|
+
onChange: () => { selectedCoverId = cl.id; drawCoverList(); },
|
|
543
|
+
}),
|
|
544
|
+
title: cl.title,
|
|
545
|
+
sub: [
|
|
546
|
+
t('jobs.applied.versions', { count: cl.version_count ?? 1 }),
|
|
547
|
+
cl.job_id && cl.job_id !== job.id ? t('jobs.applied.otherJob') : null,
|
|
548
|
+
cl.is_primary ? t('jobs.applied.primary') : null,
|
|
549
|
+
].filter(Boolean).join(' · '),
|
|
550
|
+
})),
|
|
551
|
+
];
|
|
552
|
+
if (!showAllCovers && !scoped.length) {
|
|
553
|
+
rows.push(el('p', { class: 'muted text-sm', style: { margin: '2px 0 0' } }, t('jobs.applied.noCovers')));
|
|
554
|
+
}
|
|
555
|
+
mount(coverList, ...rows);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function drawDocuments() {
|
|
559
|
+
const docs = documents || [];
|
|
560
|
+
if (!docs.length) {
|
|
561
|
+
mount(docList, el('p', { class: 'muted text-sm', style: { margin: 0 } }, t('jobs.applied.noDocs')));
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
mount(docList, ...docs.map((doc) => pickRow({
|
|
565
|
+
input: el('input', {
|
|
566
|
+
type: 'checkbox',
|
|
567
|
+
checked: selectedDocIds.has(doc.id),
|
|
568
|
+
onChange: (e) => {
|
|
569
|
+
if (e.target.checked) selectedDocIds.add(doc.id);
|
|
570
|
+
else selectedDocIds.delete(doc.id);
|
|
571
|
+
},
|
|
572
|
+
}),
|
|
573
|
+
title: doc.title,
|
|
574
|
+
sub: [
|
|
575
|
+
t('kind.' + doc.kind),
|
|
576
|
+
doc.is_primary ? t('jobs.applied.primary') : null,
|
|
577
|
+
].filter(Boolean).join(' · '),
|
|
578
|
+
})));
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const toggleCovers = Btn(t('jobs.applied.toggleOther'), {
|
|
582
|
+
sm: true,
|
|
583
|
+
variant: 'ghost',
|
|
584
|
+
onClick: () => {
|
|
585
|
+
showAllCovers = !showAllCovers;
|
|
586
|
+
toggleCovers.querySelector('span').textContent = showAllCovers ? t('jobs.applied.toggleScoped') : t('jobs.applied.toggleOther');
|
|
587
|
+
drawCoverList();
|
|
588
|
+
},
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
drawCoverList();
|
|
592
|
+
drawDocuments();
|
|
593
|
+
|
|
594
|
+
openModal({
|
|
595
|
+
title: t('jobs.applied.title'),
|
|
596
|
+
size: 'lg',
|
|
597
|
+
body: el('div', { class: 'stack-3' },
|
|
598
|
+
el('div', { class: 'grid grid--2' },
|
|
599
|
+
Field(t('jobs.applied.fieldSubmittedAt'), submittedAt),
|
|
600
|
+
Field(t('jobs.applied.fieldChannel'), channel)),
|
|
601
|
+
Field(el('div', { class: 'flex between center' }, el('span', {}, t('jobs.applied.fieldCover')), toggleCovers), coverList),
|
|
602
|
+
Field(t('jobs.applied.fieldDocs'), docList),
|
|
603
|
+
Field(t('jobs.applied.fieldNote'), note),
|
|
604
|
+
),
|
|
605
|
+
footer: (close) => [
|
|
606
|
+
Btn(t('jobs.applied.cancel'), { onClick: close }),
|
|
607
|
+
SubmitBtn(t('jobs.applied.statusOnly'), async () => {
|
|
608
|
+
await changeStatus('applied');
|
|
609
|
+
close();
|
|
610
|
+
}, { variant: 'ghost' }),
|
|
611
|
+
SubmitBtn(t('jobs.applied.recordAndDone'), async () => {
|
|
612
|
+
const submission = {
|
|
613
|
+
submitted_at: submittedAt.value || undefined,
|
|
614
|
+
channel: channel.value.trim() || undefined,
|
|
615
|
+
cover_letter_id: selectedCoverId || undefined,
|
|
616
|
+
document_ids: [...selectedDocIds],
|
|
617
|
+
};
|
|
618
|
+
await changeStatus('applied', {
|
|
619
|
+
note: note.value.trim() || undefined,
|
|
620
|
+
submission,
|
|
621
|
+
});
|
|
622
|
+
close();
|
|
623
|
+
}),
|
|
624
|
+
],
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function TimelineCard(timeline) {
|
|
629
|
+
const list = timeline || [];
|
|
630
|
+
if (!list.length) return null;
|
|
631
|
+
return Card({
|
|
632
|
+
title: t('jobs.timeline.title'),
|
|
633
|
+
sub: t('jobs.timeline.cardSub', { count: list.length }),
|
|
634
|
+
body: el('div', { class: 'timeline timeline--job' },
|
|
635
|
+
...list.map((event, idx) => TimelineItem(event, idx === list.length - 1))),
|
|
480
636
|
});
|
|
481
637
|
}
|
|
482
638
|
|
|
639
|
+
// Timeline title is enum-derivable from event.type, so render it client-side
|
|
640
|
+
// (like status badges) instead of the server-provided event.title — keeps the
|
|
641
|
+
// timeline localized in both the real app and the demo. Unknown types fall back
|
|
642
|
+
// to the server title.
|
|
643
|
+
function timelineTitle(event) {
|
|
644
|
+
if (event.type === 'application_status_changed') {
|
|
645
|
+
const st = event.payload?.status;
|
|
646
|
+
return st ? t('status.' + st) : event.title;
|
|
647
|
+
}
|
|
648
|
+
const key = 'jobs.timeline.type.' + event.type;
|
|
649
|
+
const label = t(key);
|
|
650
|
+
return label === key ? event.title : label;
|
|
651
|
+
}
|
|
652
|
+
// Summaries are mostly free-form user data (company·position, cover-letter title,
|
|
653
|
+
// notes) → keep as-is; only the count/score ones are derivable from the payload.
|
|
654
|
+
function timelineSummary(event) {
|
|
655
|
+
const p = event.payload || {};
|
|
656
|
+
if (event.type === 'fit_analysis_saved' && p.score != null) return t('jobs.timeline.fitScore', { score: p.score });
|
|
657
|
+
if (event.type === 'interview_prep_saved' && typeof p.question_count === 'number' && p.question_count > 0)
|
|
658
|
+
return t('jobs.timeline.questionCount', { count: p.question_count });
|
|
659
|
+
return event.summary;
|
|
660
|
+
}
|
|
661
|
+
function TimelineItem(event, isCurrent) {
|
|
662
|
+
const summary = timelineSummary(event);
|
|
663
|
+
return el('div', { class: `tl-item${isCurrent ? ' is-current' : ''}` },
|
|
664
|
+
el('div', { class: 'tl-item__rail' },
|
|
665
|
+
el('div', { class: 'tl-item__dot' }),
|
|
666
|
+
el('div', { class: 'tl-item__line' })),
|
|
667
|
+
el('div', { class: 'tl-item__body timeline-event' },
|
|
668
|
+
el('div', { class: 'timeline-event__head' },
|
|
669
|
+
el('div', { class: 'timeline-event__title' }, timelineTitle(event)),
|
|
670
|
+
el('time', { class: 'muted text-sm', attrs: { datetime: event.occurred_at || '' } }, fmtDate(event.occurred_at))),
|
|
671
|
+
summary ? el('div', { class: 'timeline-event__summary' }, summary) : null,
|
|
672
|
+
TimelinePayload(event)));
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function TimelinePayload(event) {
|
|
676
|
+
const payload = event.payload || {};
|
|
677
|
+
const rows = [];
|
|
678
|
+
if (payload.cover_letter) rows.push(TimelineRef(t('jobs.timeline.coverLetter'), payload.cover_letter));
|
|
679
|
+
if (payload.submission) {
|
|
680
|
+
const s = payload.submission;
|
|
681
|
+
const submittedMeta = [s.submitted_at ? fmtDate(s.submitted_at) : null, s.channel].filter(Boolean).join(' · ');
|
|
682
|
+
if (submittedMeta) rows.push(el('div', { class: 'timeline-event__meta' }, submittedMeta));
|
|
683
|
+
if (s.cover_letter) rows.push(TimelineRef(t('jobs.timeline.coverLetter'), s.cover_letter));
|
|
684
|
+
if (Array.isArray(s.documents) && s.documents.length) {
|
|
685
|
+
rows.push(el('div', { class: 'timeline-docs' },
|
|
686
|
+
...s.documents.map((doc) => TimelineRef(t('kind.' + (doc.document_kind || 'other')), doc))));
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
if (!rows.length) return null;
|
|
690
|
+
return el('div', { class: 'timeline-event__extra' }, ...rows);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function TimelineRef(label, ref) {
|
|
694
|
+
const version = ref.version_no ? ` v${ref.version_no}` : '';
|
|
695
|
+
const title = `${ref.title || t('jobs.timeline.deletedDoc')}${version}`;
|
|
696
|
+
if (ref.exists && ref.route) {
|
|
697
|
+
return el('div', { class: 'timeline-ref' },
|
|
698
|
+
el('span', { class: 'timeline-ref__label' }, label),
|
|
699
|
+
el('button', { class: 'timeline-ref__link', onClick: () => navigate(ref.route) }, title));
|
|
700
|
+
}
|
|
701
|
+
return el('div', { class: 'timeline-ref timeline-ref--missing' },
|
|
702
|
+
el('span', { class: 'timeline-ref__label' }, label),
|
|
703
|
+
el('span', { class: 'timeline-ref__missing' }, title),
|
|
704
|
+
el('span', { class: 'timeline-ref__deleted' }, t('jobs.timeline.deleted')));
|
|
705
|
+
}
|
|
706
|
+
|
|
483
707
|
function RelatedCard(related) {
|
|
484
708
|
const list = related || [];
|
|
485
709
|
if (!list.length) return null;
|
|
486
710
|
return Card({
|
|
487
|
-
title: '
|
|
488
|
-
sub: '
|
|
711
|
+
title: t('jobs.related.title'),
|
|
712
|
+
sub: t('jobs.related.sub'),
|
|
489
713
|
body: el('div', { class: 'stack-2' },
|
|
490
714
|
...list.map((r) => el('div', {
|
|
491
715
|
class: 'flex between center is-clickable',
|
|
@@ -494,7 +718,7 @@ function RelatedCard(related) {
|
|
|
494
718
|
},
|
|
495
719
|
el('div', {},
|
|
496
720
|
el('div', { class: 'strong' }, r.position),
|
|
497
|
-
el('div', { class: 'muted text-sm' }, [r.company, r.deadline ?
|
|
498
|
-
Badge(r.status, r.
|
|
721
|
+
el('div', { class: 'muted text-sm' }, [r.company, r.deadline ? t('jobs.related.deadline', { date: fmtDate(r.deadline) }) : null].filter(Boolean).join(' · '))),
|
|
722
|
+
Badge(r.status, t('status.' + r.status))))),
|
|
499
723
|
});
|
|
500
724
|
}
|