careermate 0.0.5 → 0.0.7

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.
Files changed (125) hide show
  1. package/AGENTS.md +13 -10
  2. package/INSTALL.md +16 -15
  3. package/README.md +24 -24
  4. package/THIRD_PARTY_NOTICES.md +2 -3
  5. package/bin/careermate.mjs +8 -1
  6. package/dist/career-os/README.md +12 -6
  7. package/dist/career-os/eop/career-description.md +139 -0
  8. package/dist/career-os/eop/cover-letter.md +10 -5
  9. package/dist/career-os/eop/fit-analysis.md +16 -6
  10. package/dist/career-os/eop/interview-prep.md +209 -21
  11. package/dist/career-os/eop/job-analysis.md +6 -4
  12. package/dist/career-os/eop/profile-extraction.md +5 -4
  13. package/dist/career-os/knowledge/cover-letter.md +5 -4
  14. package/dist/career-os/knowledge/fit-matching.md +8 -8
  15. package/dist/career-os/knowledge/human-writing.md +2 -0
  16. package/dist/career-os/knowledge/interview-behavioral.md +76 -8
  17. package/dist/career-os/knowledge/interview-technical.md +106 -4
  18. package/dist/career-os/knowledge/linkedin-profile.md +1 -1
  19. package/dist/career-os/knowledge/networking-referrals.md +1 -1
  20. package/dist/career-os/knowledge/offer-evaluation-decision.md +10 -10
  21. package/dist/career-os/knowledge/portfolio.md +3 -3
  22. package/dist/career-os/knowledge/verifiers/ats-compat.md +8 -4
  23. package/dist/career-os/knowledge/verifiers/human-voice.md +4 -2
  24. package/dist/career-os/knowledge/verifiers/recency-staleness.md +1 -1
  25. package/dist/career-os/knowledge/verifiers/responsiveness-on-target.md +2 -2
  26. package/dist/career-os/knowledge/verifiers/truthfulness.md +1 -1
  27. package/dist/doctor.mjs +692 -71
  28. package/dist/mcp.mjs +710 -71
  29. package/dist/migrate.mjs +52 -1
  30. package/dist/public/app.js +42 -16
  31. package/dist/public/i18n/en/applications.js +23 -0
  32. package/dist/public/i18n/en/common.js +22 -0
  33. package/dist/public/i18n/en/documents.js +133 -0
  34. package/dist/public/i18n/en/home.js +75 -0
  35. package/dist/public/i18n/en/interview.js +69 -0
  36. package/dist/public/i18n/en/jobs.js +152 -0
  37. package/dist/public/i18n/en/nav.js +18 -0
  38. package/dist/public/i18n/en/profile.js +201 -0
  39. package/dist/public/i18n/en/settings.js +127 -0
  40. package/dist/public/i18n/en/status.js +22 -0
  41. package/dist/public/i18n/en.js +17 -0
  42. package/dist/public/i18n/ko/applications.js +23 -0
  43. package/dist/public/i18n/ko/common.js +22 -0
  44. package/dist/public/i18n/ko/documents.js +133 -0
  45. package/dist/public/i18n/ko/home.js +63 -0
  46. package/dist/public/i18n/ko/interview.js +69 -0
  47. package/dist/public/i18n/ko/jobs.js +152 -0
  48. package/dist/public/i18n/ko/nav.js +18 -0
  49. package/dist/public/i18n/ko/profile.js +201 -0
  50. package/dist/public/i18n/ko/settings.js +127 -0
  51. package/dist/public/i18n/ko/status.js +23 -0
  52. package/dist/public/i18n/ko.js +18 -0
  53. package/dist/public/i18n.js +55 -0
  54. package/dist/public/index.html +1 -0
  55. package/dist/public/lib.js +24 -17
  56. package/dist/public/pages/applications.js +17 -21
  57. package/dist/public/pages/documents.js +153 -107
  58. package/dist/public/pages/home.js +88 -53
  59. package/dist/public/pages/interview.js +105 -51
  60. package/dist/public/pages/jobs.js +330 -106
  61. package/dist/public/pages/profile.js +157 -152
  62. package/dist/public/pages/settings.js +154 -102
  63. package/dist/public/styles.css +28 -1
  64. package/dist/web.mjs +495 -75
  65. package/docs/FAQ.md +23 -3
  66. package/docs/MCP_TOOLS.md +22 -13
  67. package/docs/START_WORKFLOW.md +3 -3
  68. package/docs/SUPPORTED_AI_APPS.md +13 -0
  69. package/docs/WORKFLOWS.md +77 -20
  70. package/docs/career-os/README.md +12 -6
  71. package/docs/career-os/eop/career-description.md +139 -0
  72. package/docs/career-os/eop/cover-letter.md +10 -5
  73. package/docs/career-os/eop/fit-analysis.md +16 -6
  74. package/docs/career-os/eop/interview-prep.md +209 -21
  75. package/docs/career-os/eop/job-analysis.md +6 -4
  76. package/docs/career-os/eop/profile-extraction.md +5 -4
  77. package/docs/career-os/knowledge/cover-letter.md +5 -4
  78. package/docs/career-os/knowledge/fit-matching.md +8 -8
  79. package/docs/career-os/knowledge/human-writing.md +2 -0
  80. package/docs/career-os/knowledge/interview-behavioral.md +76 -8
  81. package/docs/career-os/knowledge/interview-technical.md +106 -4
  82. package/docs/career-os/knowledge/linkedin-profile.md +1 -1
  83. package/docs/career-os/knowledge/networking-referrals.md +1 -1
  84. package/docs/career-os/knowledge/offer-evaluation-decision.md +10 -10
  85. package/docs/career-os/knowledge/portfolio.md +3 -3
  86. package/docs/career-os/knowledge/verifiers/ats-compat.md +8 -4
  87. package/docs/career-os/knowledge/verifiers/human-voice.md +4 -2
  88. package/docs/career-os/knowledge/verifiers/recency-staleness.md +1 -1
  89. package/docs/career-os/knowledge/verifiers/responsiveness-on-target.md +2 -2
  90. package/docs/career-os/knowledge/verifiers/truthfulness.md +1 -1
  91. package/package.json +2 -2
  92. package/packages/core/src/onboarding.ts +2 -1
  93. package/packages/core/src/services.ts +134 -17
  94. package/packages/core/src/summary.ts +59 -1
  95. package/packages/core/src/verify/lint.ts +4 -0
  96. package/packages/db/src/index.ts +1 -0
  97. package/packages/db/src/repositories.ts +0 -0
  98. package/packages/db/src/schema.ts +17 -0
  99. package/packages/exporters/src/index.ts +28 -17
  100. package/packages/knowledge/src/index.ts +55 -9
  101. package/packages/mcp-tools/src/tools.ts +250 -24
  102. package/packages/mcp-tools/src/update.ts +30 -15
  103. package/packages/parsers/src/documents.ts +12 -1
  104. package/packages/prompts/src/humanize.ts +46 -0
  105. package/packages/prompts/src/index.ts +5 -1
  106. package/packages/prompts/src/install.ts +4 -4
  107. package/packages/prompts/src/onboarding.ts +4 -4
  108. package/packages/prompts/src/system.ts +9 -7
  109. package/packages/shared/src/enums.ts +14 -4
  110. package/packages/shared/src/schemas.ts +41 -0
  111. package/packages/workflows/src/definitions.ts +45 -8
  112. package/scripts/init.ts +284 -13
  113. package/dist/site/analytics.js +0 -97
  114. package/dist/site/favicon.svg +0 -1
  115. package/dist/site/fonts/PretendardVariable.woff2 +0 -0
  116. package/dist/site/icon-180.png +0 -0
  117. package/dist/site/icon-512.png +0 -0
  118. package/dist/site/index.html +0 -465
  119. package/dist/site/landing.js +0 -163
  120. package/dist/site/llms-install.txt +0 -218
  121. package/dist/site/robots.txt +0 -4
  122. package/dist/site/sitemap.xml +0 -21
  123. package/dist/site/start.html +0 -356
  124. package/dist/site/style.css +0 -665
  125. 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('공고 추가', { icon: 'plus', variant: 'primary', onClick: () => openJobModal(ctx) }),
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('공고 추가', { icon: 'plus', variant: 'primary', sm: true, onClick: () => openJobModal(ctx) });
36
- const search = Input({ type: 'search', placeholder: '회사·직무 검색', attrs: { 'aria-label': '공고 검색' } });
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 ? `마감 ${fmtDate(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)}` }, `${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.status_label))),
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: `${jobs.length}개`,
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: '회사명', attrs: { required: 'required' } });
95
- const position = Input({ value: v.position || '', placeholder: '직무 / 포지션명', attrs: { required: 'required' } });
96
- const url = Input({ value: v.url || '', placeholder: 'https://…', type: 'url' });
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: 'YYYY-MM-DD' });
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('먼저 공고 원문을 붙여넣어 주세요.'); return; }
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('회사 *', company),
143
- Field('직무 *', position)),
144
- Field('공고 URL', url),
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('위치', location),
147
- Field('고용형태', employment),
148
- Field('마감일', deadline, 'YYYY-MM-DD')),
149
- Field('출처', source),
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', {}, '공고 원문'), tidyBtn),
152
+ el('div', { class: 'flex between center' }, el('span', {}, t('jobs.form.field.description')), tidyBtn),
152
153
  description,
153
- '원문을 붙여넣고 "정리"를 누르면 회사·직무·키워드·마감일을 자동으로 채워봅니다.'),
154
- Field('핵심 키워드', keywords, '쉼표로 구분'),
155
- Field('자격요건 · 우대사항', requirements, '한 줄에 하나씩'),
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('공고 저장', built.submit);
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('취소', { onClick: close }),
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('공고 저장', built.submit);
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('취소', { onClick: close }),
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('공고 목록으로', { icon: 'chevronRight', variant: 'primary', onClick: () => navigate('/jobs') }),
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(`${job.company} · ${job.position}`);
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.label, selected: s.value === job.status })),
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
- const res = await put(`/api/applications/${jobId}/status`, { status });
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('수정', { icon: 'edit', sm: true, variant: 'ghost', onClick: () => openEditModal(ctx, job, remount) });
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: `'${job.company} · ${job.position}' 공고를 삭제할까요? 연결된 지원 기록도 함께 사라집니다.`,
284
- confirmLabel: '삭제', danger: true,
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('목록', { icon: 'chevronRight', sm: true, variant: 'ghost', onClick: () => navigate('/jobs') }),
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
- Field('상태', statusSelect),
305
- job.url ? el('a', { class: 'btn btn--ghost btn--sm', href: job.url, attrs: { target: '_blank', rel: 'noopener noreferrer' } }, icon('external'), el('span', {}, '공고 원문 열기')) : null,
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.status_label)),
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', {}, '위치'), el('dd', {}, job.location || el('span', { class: 'muted' }, '미정')),
341
- el('dt', {}, '고용형태'), el('dd', {}, job.employment_type || el('span', { class: 'muted' }, '미정')),
342
- el('dt', {}, '마감일'), el('dd', {}, job.deadline ? fmtDate(job.deadline) : el('span', { class: 'muted' }, '없음')),
343
- el('dt', {}, '출처'), el('dd', {}, job.source || el('span', { class: 'muted' }, '직접 입력')),
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: '공고 정보', body });
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 ? '종합 적합도 (100점 만점)' : '점수 미산정'),
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: '적합도 분석', body: el('div', { class: 'stack-3' }, ...body) });
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: `${list.length}개`,
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('/documents'),
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' }, `버전 ${cl.version_count ?? 1}개`))),
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('면접 준비 보기', { sm: true, variant: 'ghost', onClick: () => navigate('/interview') }),
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 ? `예상 질문 ${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' }, '1분 자기소개 초안도 함께 저장되어 있습니다.')
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
- eligible
478
- ? '서류 단계를 통과했어요. 면접 준비 탭에서 예상 질문과 1분 자기소개를 정리할 수 있어요.'
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 ? `마감 ${fmtDate(r.deadline)}` : null].filter(Boolean).join(' · '))),
498
- Badge(r.status, r.status_label)))),
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
  }