careermate 0.0.8 → 0.0.10
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 +78 -81
- package/bin/careermate.mjs +11 -2
- package/dist/career-os/eop/cover-letter.md +10 -4
- package/dist/career-os/eop/interview-prep.md +1 -0
- package/dist/career-os/knowledge/cover-letter.md +4 -0
- package/dist/career-os/knowledge/verifiers/human-voice.md +7 -2
- package/dist/doctor.mjs +18138 -6866
- package/dist/mcp.mjs +14820 -4124
- package/dist/migrate.mjs +14436 -3712
- package/dist/pdf.worker.min.mjs +4 -4
- package/dist/public/app.js +10 -19
- package/dist/public/i18n/en/applications.js +19 -0
- package/dist/public/i18n/en/common.js +6 -0
- package/dist/public/i18n/en/documents.js +1 -0
- package/dist/public/i18n/en/home.js +8 -1
- package/dist/public/i18n/en/jobs.js +19 -0
- package/dist/public/i18n/en/nav.js +1 -0
- 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 +5 -0
- package/dist/public/i18n/ko/documents.js +1 -0
- package/dist/public/i18n/ko/home.js +8 -1
- package/dist/public/i18n/ko/jobs.js +19 -0
- package/dist/public/i18n/ko/nav.js +1 -0
- package/dist/public/i18n/ko/settings.js +5 -2
- package/dist/public/lib.js +29 -0
- package/dist/public/pages/applications.js +86 -10
- package/dist/public/pages/documents.js +25 -12
- package/dist/public/pages/home.js +24 -4
- package/dist/public/pages/insights.js +156 -0
- package/dist/public/pages/interview.js +11 -4
- package/dist/public/pages/jobs.js +92 -4
- package/dist/public/pages/settings.js +2 -2
- package/dist/public/styles.css +16 -0
- package/dist/web.mjs +17079 -5651
- package/docs/FAQ.md +13 -3
- package/docs/LOCAL_FIRST.md +32 -0
- package/docs/MCP_TOOLS.md +22 -9
- package/docs/REVIEW_IMPLEMENTATION_PLAN.md +22 -0
- package/docs/career-os/eop/cover-letter.md +10 -4
- package/docs/career-os/eop/interview-prep.md +1 -0
- package/docs/career-os/knowledge/cover-letter.md +4 -0
- package/docs/career-os/knowledge/verifiers/human-voice.md +7 -2
- package/package.json +9 -5
- package/packages/core/src/data-health.ts +161 -0
- package/packages/core/src/fit-explain.ts +63 -0
- package/packages/core/src/index.ts +4 -0
- package/packages/core/src/jobsearch.ts +99 -54
- package/packages/core/src/offers.ts +41 -0
- package/packages/core/src/reuse-insights.ts +126 -0
- package/packages/core/src/services.ts +5 -1
- package/packages/core/src/summary.ts +245 -19
- package/packages/db/src/connection.ts +102 -14
- package/packages/db/src/index.ts +3 -0
- package/packages/db/src/interview-retrospectives.ts +149 -0
- package/packages/db/src/provenance.ts +125 -0
- package/packages/db/src/repositories.ts +140 -8
- package/packages/db/src/schema.ts +140 -8
- package/packages/mcp-tools/src/result.ts +9 -0
- package/packages/mcp-tools/src/tools.ts +143 -20
- package/packages/parsers/src/documents.ts +7 -1
- package/packages/prompts/src/humanize.ts +4 -0
- package/packages/prompts/src/system.ts +21 -64
- package/packages/shared/src/schemas.ts +134 -0
- package/scripts/init.ts +2 -0
- package/tsconfig.json +9 -10
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// Documents — cover letters (with version history), career descriptions, and résumés/other documents.
|
|
2
2
|
// XSS-safe: all DB/user text rendered via el() textContent / .doc-preview (never innerHTML).
|
|
3
3
|
import {
|
|
4
|
-
el, get, post, put, del, Card, Badge, Btn,
|
|
4
|
+
el, get, post, put, del, Card, Badge, Btn, SubmitBtn, EmptyState,
|
|
5
5
|
Field, Input, Textarea, Select, openModal, confirmDialog,
|
|
6
|
-
toastOk, toastError, copyText, fmtRelative, mount, meta, ExportMenu,
|
|
6
|
+
toastOk, toastError, copyText, fmtRelative, mount, meta, ExportMenu, Menu,
|
|
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
|
}
|
|
@@ -135,12 +144,16 @@ function CoverRow(cl, reload) {
|
|
|
135
144
|
cl.is_primary ? Badge('accent', t('documents.cover.primary')) : null,
|
|
136
145
|
);
|
|
137
146
|
|
|
147
|
+
// Two primary actions stay visible (열기·내보내기); the rest fold into an
|
|
148
|
+
// overflow menu so the row reads calmly instead of a wall of five buttons.
|
|
138
149
|
const actions = el('div', { class: 'flex gap-2 wrap' },
|
|
139
150
|
Btn(t('documents.cover.open'), { sm: true, icon: 'external', onClick: () => openCoverDetail(cl.id, reload) }),
|
|
140
|
-
Btn(t('documents.cover.copy'), { sm: true, variant: 'ghost', icon: 'copy', onClick: () => copyText(cl.current_content || ''), disabled: !cl.current_content }),
|
|
141
151
|
ExportMenu(`/api/export/cover-letter/${cl.id}`),
|
|
142
|
-
|
|
143
|
-
|
|
152
|
+
Menu(t('documents.cover.more'), [
|
|
153
|
+
cl.current_content ? { label: t('documents.cover.copy'), icon: 'copy', onClick: () => copyText(cl.current_content || '') } : null,
|
|
154
|
+
cl.is_primary ? null : { label: t('documents.cover.setPrimary'), icon: 'check', onClick: () => setPrimaryCover(cl.id, reload) },
|
|
155
|
+
{ label: t('documents.cover.delete'), icon: 'trash', danger: true, onClick: () => removeCover(cl, reload) },
|
|
156
|
+
], { sm: true, icon: null }),
|
|
144
157
|
);
|
|
145
158
|
|
|
146
159
|
return Card({
|
|
@@ -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
|
|
|
@@ -141,6 +142,14 @@ function Pipeline(breakdown) {
|
|
|
141
142
|
if (total === 0) {
|
|
142
143
|
body = el('p', { class: 'muted', style: { margin: 0 } },
|
|
143
144
|
t('home.pipeline.empty'));
|
|
145
|
+
} else if (total < 3) {
|
|
146
|
+
// Too few applications for a stacked bar to read as anything but noise —
|
|
147
|
+
// show the current stage(s) as one calm line until the pipeline fills in.
|
|
148
|
+
const detail = PIPE_ORDER.filter((st) => c(st) > 0)
|
|
149
|
+
.map((st) => t('home.pipeline.small.item', { status: t('status.' + st), count: c(st) }))
|
|
150
|
+
.join(' · ');
|
|
151
|
+
body = el('p', { class: 'muted', style: { margin: 0 } },
|
|
152
|
+
t('home.pipeline.small', { detail }));
|
|
144
153
|
} else {
|
|
145
154
|
const segs = PIPE_ORDER.filter((st) => c(st) > 0).map((st) =>
|
|
146
155
|
el('div', {
|
|
@@ -210,16 +219,27 @@ function ActionLane(s) {
|
|
|
210
219
|
push(it.job.id, ListRow({
|
|
211
220
|
leading: statusDot('document_passed'),
|
|
212
221
|
title: it.job.company,
|
|
213
|
-
sub:
|
|
222
|
+
sub: t('home.actionLane.reason.interview'),
|
|
214
223
|
trailing: el('span', { class: 'badge badge--accent' }, el('span', { class: 'dot' }), t('home.actionLane.interviewPrep')),
|
|
215
224
|
onClick: () => navigate(`/jobs/${it.job.id}`),
|
|
216
225
|
}));
|
|
217
226
|
}
|
|
227
|
+
// Hiring-schedule stages (면접·인적성·발표) sit right after interview prep and before
|
|
228
|
+
// deadlines: they're the most time-bound "be there on this date" items.
|
|
229
|
+
for (const it of s.upcoming_schedule || []) {
|
|
230
|
+
push(it.job.id, ListRow({
|
|
231
|
+
leading: statusDot('interview'),
|
|
232
|
+
title: it.job.company,
|
|
233
|
+
sub: it.time ? `${it.label} · ${it.time}` : it.label,
|
|
234
|
+
trailing: scheduleDdayBadge(it.d_day),
|
|
235
|
+
onClick: () => navigate(`/jobs/${it.job.id}`),
|
|
236
|
+
}));
|
|
237
|
+
}
|
|
218
238
|
for (const it of s.deadlines || []) {
|
|
219
239
|
push(it.job.id, ListRow({
|
|
220
240
|
leading: statusDot(it.status),
|
|
221
241
|
title: it.job.company,
|
|
222
|
-
sub:
|
|
242
|
+
sub: t('home.actionLane.reason.deadline'),
|
|
223
243
|
trailing: ddayBadge(it.days_left),
|
|
224
244
|
onClick: () => navigate(`/jobs/${it.job.id}`),
|
|
225
245
|
}));
|
|
@@ -228,8 +248,8 @@ function ActionLane(s) {
|
|
|
228
248
|
push(it.job.id, ListRow({
|
|
229
249
|
leading: statusDot('applied'),
|
|
230
250
|
title: it.job.company,
|
|
231
|
-
sub:
|
|
232
|
-
trailing: Badge('accent', t('home.actionLane.
|
|
251
|
+
sub: t('home.actionLane.reason.followup', { n: it.days_since }),
|
|
252
|
+
trailing: Badge('accent', t('home.actionLane.followupBadge')),
|
|
233
253
|
onClick: () => navigate(`/jobs/${it.job.id}`),
|
|
234
254
|
}));
|
|
235
255
|
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { el, get, post, Card, Btn, icon, mount, toastOk, toastError } from '/lib.js';
|
|
2
|
+
import { getLang } from '/i18n.js';
|
|
3
|
+
|
|
4
|
+
const copy = {
|
|
5
|
+
ko: {
|
|
6
|
+
title: '진단·데이터 건강도',
|
|
7
|
+
subtitle: '로컬 데이터 상태, 모순, 출처 확인 대상과 경험 재사용 현황을 한곳에서 점검합니다.',
|
|
8
|
+
runtime: '실행 환경',
|
|
9
|
+
health: '데이터 건강도',
|
|
10
|
+
provenance: '사람 확인이 필요한 정보',
|
|
11
|
+
reuse: '경험·프로젝트 재사용 지도',
|
|
12
|
+
healthy: '정상',
|
|
13
|
+
failed: '확인 필요',
|
|
14
|
+
verify: '사람이 확인함',
|
|
15
|
+
empty: '확인할 문제가 없습니다.',
|
|
16
|
+
score: '건강도 점수',
|
|
17
|
+
times: '회 사용',
|
|
18
|
+
local: 'DB와 커리어 데이터는 이 컴퓨터에 저장됩니다.',
|
|
19
|
+
network: '업데이트 확인·설치 문서·채용 검색은 선택적으로 인터넷을 사용할 수 있습니다.',
|
|
20
|
+
},
|
|
21
|
+
en: {
|
|
22
|
+
title: 'Diagnostics & data health',
|
|
23
|
+
subtitle: 'Review local storage, contradictions, provenance, and experience reuse.',
|
|
24
|
+
runtime: 'Runtime checks',
|
|
25
|
+
health: 'Data health',
|
|
26
|
+
provenance: 'Needs human verification',
|
|
27
|
+
reuse: 'Experience & project reuse map',
|
|
28
|
+
healthy: 'Healthy',
|
|
29
|
+
failed: 'Needs attention',
|
|
30
|
+
verify: 'Mark human verified',
|
|
31
|
+
empty: 'No issues found.',
|
|
32
|
+
score: 'Health score',
|
|
33
|
+
times: 'uses',
|
|
34
|
+
local: 'Your database and career records are stored on this computer.',
|
|
35
|
+
network: 'Update checks, install docs, and job search may optionally use the internet.',
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
function text() {
|
|
40
|
+
return copy[getLang()] || copy.ko;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function severityBadge(severity) {
|
|
44
|
+
const label = severity === 'error' ? 'ERROR' : severity === 'warning' ? 'WARN' : 'INFO';
|
|
45
|
+
return el('span', { class: `badge badge--${severity === 'error' ? 'rejected' : severity === 'warning' ? 'planned' : 'draft'}` }, label);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function render(ctx) {
|
|
49
|
+
const c = text();
|
|
50
|
+
ctx.setTitle(c.title);
|
|
51
|
+
ctx.setActions([Btn(c.title, { icon: 'check', sm: true, variant: 'ghost', onClick: () => render(ctx) })]);
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const diagnostics = await get('/api/diagnostics');
|
|
55
|
+
const checks = diagnostics.checks || [];
|
|
56
|
+
const report = diagnostics.data_health || { score: 0, issues: [], counts: {} };
|
|
57
|
+
const unverified = diagnostics.unverified_entities || [];
|
|
58
|
+
const reuseMap = diagnostics.reuse_map || [];
|
|
59
|
+
|
|
60
|
+
const localFirst = Card({
|
|
61
|
+
title: 'Local-first',
|
|
62
|
+
body: el('div', { class: 'stack-2' },
|
|
63
|
+
el('div', { class: 'callout callout--privacy' }, icon('lock'), el('div', {},
|
|
64
|
+
el('div', { class: 'callout__title' }, c.local),
|
|
65
|
+
el('div', { class: 'callout__body' }, c.network))),
|
|
66
|
+
),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const runtime = Card({
|
|
70
|
+
title: c.runtime,
|
|
71
|
+
body: el('div', { class: 'stack-2' }, ...checks.map((check) =>
|
|
72
|
+
el('div', { class: 'flex between gap-3' },
|
|
73
|
+
el('div', {},
|
|
74
|
+
el('div', { class: 'strong' }, check.label),
|
|
75
|
+
el('div', { class: 'muted text-sm tnum' }, check.detail || '—')),
|
|
76
|
+
el('span', { class: `badge badge--${check.ok ? 'final_passed' : 'rejected'}` }, check.ok ? c.healthy : c.failed),
|
|
77
|
+
))),
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const issueList = report.issues?.length
|
|
81
|
+
? el('div', { class: 'stack-2' }, ...report.issues.map((issue) =>
|
|
82
|
+
el('div', { class: 'flex gap-3', style: { alignItems: 'flex-start' } },
|
|
83
|
+
severityBadge(issue.severity),
|
|
84
|
+
el('div', {},
|
|
85
|
+
el('div', { class: 'strong' }, issue.message),
|
|
86
|
+
el('div', { class: 'muted text-sm' }, `${issue.entity_type}${issue.entity_id ? ` · ${issue.entity_id}` : ''}`)),
|
|
87
|
+
)))
|
|
88
|
+
: el('p', { class: 'muted' }, c.empty);
|
|
89
|
+
|
|
90
|
+
const health = Card({
|
|
91
|
+
title: c.health,
|
|
92
|
+
actions: [el('span', { class: 'badge badge--final_passed tnum' }, `${c.score} ${report.score}`)],
|
|
93
|
+
body: issueList,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const reuse = Card({
|
|
97
|
+
title: c.reuse,
|
|
98
|
+
body: reuseMap.length
|
|
99
|
+
? el('div', { class: 'stack-2' }, ...reuseMap.map((item) =>
|
|
100
|
+
el('div', { class: 'flex between gap-3', style: { alignItems: 'flex-start' } },
|
|
101
|
+
el('div', {},
|
|
102
|
+
el('div', { class: 'strong' }, item.label),
|
|
103
|
+
el('div', { class: 'muted text-sm' },
|
|
104
|
+
item.cover_letters?.length
|
|
105
|
+
? item.cover_letters.map((letter) => [letter.company, letter.position, letter.title].filter(Boolean).join(' · ')).join(' / ')
|
|
106
|
+
: c.empty)),
|
|
107
|
+
el('span', { class: `badge badge--${item.reuse_count ? 'final_passed' : 'draft'} tnum` }, `${item.reuse_count} ${c.times}`),
|
|
108
|
+
)))
|
|
109
|
+
: el('p', { class: 'muted' }, c.empty),
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
async function verify(item, button) {
|
|
113
|
+
try {
|
|
114
|
+
button.disabled = true;
|
|
115
|
+
await post('/api/provenance/verify', {
|
|
116
|
+
entity_type: item.entity_type,
|
|
117
|
+
entity_id: item.entity_id,
|
|
118
|
+
verified: true,
|
|
119
|
+
});
|
|
120
|
+
toastOk(c.verify);
|
|
121
|
+
await render(ctx);
|
|
122
|
+
} catch (error) {
|
|
123
|
+
button.disabled = false;
|
|
124
|
+
toastError(error);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const provenance = Card({
|
|
129
|
+
title: c.provenance,
|
|
130
|
+
body: unverified.length
|
|
131
|
+
? el('div', { class: 'stack-2' }, ...unverified.map((item) => {
|
|
132
|
+
const button = Btn(c.verify, { icon: 'check', sm: true, variant: 'ghost' });
|
|
133
|
+
button.addEventListener('click', () => verify(item, button));
|
|
134
|
+
return el('div', { class: 'flex between gap-3' },
|
|
135
|
+
el('div', {},
|
|
136
|
+
el('div', { class: 'strong' }, `${item.entity_type} · ${item.entity_id}`),
|
|
137
|
+
el('div', { class: 'muted text-sm' }, item.source_excerpt || item.source)),
|
|
138
|
+
button,
|
|
139
|
+
);
|
|
140
|
+
}))
|
|
141
|
+
: el('p', { class: 'muted' }, c.empty),
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
mount(ctx.view, el('div', { class: 'stack-4' },
|
|
145
|
+
el('p', { class: 'muted', style: { marginTop: 0 } }, c.subtitle),
|
|
146
|
+
localFirst,
|
|
147
|
+
runtime,
|
|
148
|
+
health,
|
|
149
|
+
reuse,
|
|
150
|
+
provenance,
|
|
151
|
+
));
|
|
152
|
+
} catch (error) {
|
|
153
|
+
toastError(error);
|
|
154
|
+
mount(ctx.view, el('div', { class: 'card' }, el('div', { class: 'card__body' }, String(error))));
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -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
|
}));
|
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
el, get, post, put, del, icon, navigate, Card, Badge, Btn, SubmitBtn,
|
|
7
7
|
EmptyState, Chips, Field, Input, Textarea, Select,
|
|
8
8
|
openModal, closeModal, confirmDialog, toastOk, toastError,
|
|
9
|
-
fmtDate, scoreClass, mount, meta, linesToArray, csvToArray,
|
|
9
|
+
fmtDate, scoreClass, mount, meta, linesToArray, csvToArray, daysUntil, scheduleDdayBadge,
|
|
10
10
|
} from '/lib.js';
|
|
11
11
|
import { t } from '/i18n.js';
|
|
12
12
|
|
|
@@ -314,11 +314,11 @@ async function renderDetail(ctx, jobId) {
|
|
|
314
314
|
]);
|
|
315
315
|
|
|
316
316
|
// --- Page head ------------------------------------------------------------
|
|
317
|
-
// 수정은 상단바(ctx.setActions)에만 둔다 — 여기엔 상태
|
|
317
|
+
// 수정은 상단바(ctx.setActions)에만 둔다 — 여기엔 상태 변경·원문만.
|
|
318
|
+
// 삭제는 위험 액션이라 페이지 최하단 '위험 구역'으로 강등한다(아래 DangerZone).
|
|
318
319
|
const headActions = el('div', { class: 'flex gap-2 center wrap' },
|
|
319
320
|
statusSelect,
|
|
320
321
|
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,
|
|
321
|
-
delBtn,
|
|
322
322
|
);
|
|
323
323
|
|
|
324
324
|
const head = el('div', { class: 'page-head' },
|
|
@@ -340,13 +340,27 @@ async function renderDetail(ctx, jobId) {
|
|
|
340
340
|
CoverLettersCard(job.cover_letters),
|
|
341
341
|
InterviewCard(job)),
|
|
342
342
|
PostingCard(job),
|
|
343
|
+
CompanyResearchCard(job),
|
|
343
344
|
FitCard(job.fit),
|
|
344
345
|
TimelineCard(job.timeline),
|
|
345
346
|
RelatedCard(job.related),
|
|
347
|
+
DangerZone(delBtn),
|
|
346
348
|
);
|
|
347
349
|
mount(ctx.view, wrap);
|
|
348
350
|
}
|
|
349
351
|
|
|
352
|
+
// Delete is destructive, so it sits at the very bottom in a quiet 위험 구역 row
|
|
353
|
+
// (not a card, per the design guide) instead of competing in the top-right header.
|
|
354
|
+
function DangerZone(delBtn) {
|
|
355
|
+
return el('div', { class: 'stack-2', style: { marginTop: '8px' } },
|
|
356
|
+
el('div', { class: 'divider' }),
|
|
357
|
+
el('div', { class: 'flex between center wrap gap-3' },
|
|
358
|
+
el('div', { style: { maxWidth: '440px' } },
|
|
359
|
+
el('div', { class: 'strong text-sm' }, t('jobs.detail.dangerZone')),
|
|
360
|
+
el('div', { class: 'muted text-sm', style: { lineHeight: '1.6' } }, t('jobs.detail.dangerZoneHint'))),
|
|
361
|
+
delBtn));
|
|
362
|
+
}
|
|
363
|
+
|
|
350
364
|
/* ----------------------------------------------------------- detail cards */
|
|
351
365
|
|
|
352
366
|
function PostingCard(job) {
|
|
@@ -372,6 +386,41 @@ function PostingCard(job) {
|
|
|
372
386
|
...job.requirements.map((r) => el('li', {}, r)))));
|
|
373
387
|
}
|
|
374
388
|
|
|
389
|
+
if (job.questions && job.questions.length) {
|
|
390
|
+
// 한국 문항형 자소서: 회사 지정 문항·글자수 한도. AI가 문항별 답변을 쓰고 한도를
|
|
391
|
+
// 점검할 원본이자, 사용자가 대시보드에서 요구 문항을 그대로 볼 수 있게 한다.
|
|
392
|
+
body.push(el('div', { class: 'mt-3' },
|
|
393
|
+
el('div', { class: 'muted text-sm mb-2' }, t('jobs.questions.title')),
|
|
394
|
+
el('ol', { class: 'stack-2', style: { margin: 0, paddingLeft: '18px' } },
|
|
395
|
+
...job.questions.map((q) => {
|
|
396
|
+
const label = questionLimitLabel(q);
|
|
397
|
+
return el('li', {},
|
|
398
|
+
el('span', {}, q.question),
|
|
399
|
+
label ? el('span', { class: 'muted text-sm', style: { marginLeft: '8px' } }, label) : null);
|
|
400
|
+
}))));
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (job.schedule && job.schedule.length) {
|
|
404
|
+
// 전형 일정: 면접·인적성·발표 등 단계별 날짜. 날짜순으로 D-day 라벨과 함께 보여주고,
|
|
405
|
+
// 이미 지난 일정은 흐리게 처리한다(v9 문항 블록과 같은 위치·스타일 패턴).
|
|
406
|
+
const sorted = job.schedule.slice().sort((a, b) => {
|
|
407
|
+
if (a.date !== b.date) return a.date < b.date ? -1 : 1;
|
|
408
|
+
return (a.time || '99:99').localeCompare(b.time || '99:99');
|
|
409
|
+
});
|
|
410
|
+
body.push(el('div', { class: 'mt-3' },
|
|
411
|
+
el('div', { class: 'muted text-sm mb-2' }, t('jobs.schedule.title')),
|
|
412
|
+
el('ul', { class: 'stack-2', style: { margin: 0, padding: 0, listStyle: 'none' } },
|
|
413
|
+
...sorted.map((s) => {
|
|
414
|
+
const dDay = daysUntil(s.date);
|
|
415
|
+
const when = s.time ? `${fmtDate(s.date)} ${s.time}` : fmtDate(s.date);
|
|
416
|
+
return el('li', { class: `flex gap-2 center${dDay != null && dDay < 0 ? ' muted' : ''}` },
|
|
417
|
+
scheduleDdayBadge(dDay),
|
|
418
|
+
el('span', { class: 'strong' }, s.label),
|
|
419
|
+
el('span', { class: 'text-sm' }, when),
|
|
420
|
+
s.note ? el('span', { class: 'muted text-sm' }, s.note) : null);
|
|
421
|
+
}))));
|
|
422
|
+
}
|
|
423
|
+
|
|
375
424
|
if (job.description) {
|
|
376
425
|
// 원문은 가장 길어 칼럼을 늘리는 주범 — 기본 접힘(아코디언)으로 둔다.
|
|
377
426
|
body.push(el('details', { class: 'disclosure mt-3' },
|
|
@@ -384,6 +433,40 @@ function PostingCard(job) {
|
|
|
384
433
|
return Card({ title: t('jobs.posting.title'), body });
|
|
385
434
|
}
|
|
386
435
|
|
|
436
|
+
// 문항 글자수 한도 라벨(예: "최대 700자(공백포함)"). 한도가 없으면 null.
|
|
437
|
+
// count_mode: with_space=공백포함 · no_space=공백제외 · byte=UTF-8 바이트(공백 구분 없음).
|
|
438
|
+
function questionLimitLabel(q) {
|
|
439
|
+
const hasMin = typeof q.min_chars === 'number';
|
|
440
|
+
const hasMax = typeof q.max_chars === 'number';
|
|
441
|
+
if (!hasMin && !hasMax) return null;
|
|
442
|
+
const unit = q.count_mode === 'byte' ? t('jobs.questions.unitByte') : t('jobs.questions.unitChar');
|
|
443
|
+
const suffix = q.count_mode === 'no_space' ? t('jobs.questions.modeNoSpace')
|
|
444
|
+
: q.count_mode === 'with_space' ? t('jobs.questions.modeWithSpace')
|
|
445
|
+
: '';
|
|
446
|
+
const parts = [];
|
|
447
|
+
if (hasMin) parts.push(t('jobs.questions.min', { n: q.min_chars, unit }));
|
|
448
|
+
if (hasMax) parts.push(t('jobs.questions.max', { n: q.max_chars, unit }));
|
|
449
|
+
return `${parts.join(' · ')}${suffix}`;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Company research the AI saved (overview·인재상·핵심가치·평판) — rendered so the
|
|
453
|
+
// user can actually SEE it in the dashboard, not just have the AI use it.
|
|
454
|
+
function CompanyResearchCard(job) {
|
|
455
|
+
const hasValues = job.core_values && job.core_values.length;
|
|
456
|
+
if (!job.company_overview && !job.talent_profile && !hasValues && !job.reputation) return null;
|
|
457
|
+
const field = (labelKey, value) => el('div', { class: 'mt-3' },
|
|
458
|
+
el('div', { class: 'muted text-sm mb-2' }, t(labelKey)),
|
|
459
|
+
el('p', { style: { margin: 0, whiteSpace: 'pre-wrap' } }, value));
|
|
460
|
+
const body = [];
|
|
461
|
+
if (job.company_overview) body.push(field('jobs.company.overview', job.company_overview));
|
|
462
|
+
if (job.talent_profile) body.push(field('jobs.company.talent', job.talent_profile));
|
|
463
|
+
if (hasValues) body.push(el('div', { class: 'mt-3' },
|
|
464
|
+
el('div', { class: 'muted text-sm mb-2' }, t('jobs.company.values')),
|
|
465
|
+
Chips(job.core_values, { accent: true })));
|
|
466
|
+
if (job.reputation) body.push(field('jobs.company.reputation', job.reputation));
|
|
467
|
+
return Card({ title: t('jobs.company.title'), body });
|
|
468
|
+
}
|
|
469
|
+
|
|
387
470
|
function FitCard(fit) {
|
|
388
471
|
if (!fit) {
|
|
389
472
|
return Card({
|
|
@@ -464,7 +547,12 @@ function CoverLettersCard(coverLetters) {
|
|
|
464
547
|
icon('file', 'muted'),
|
|
465
548
|
el('div', {},
|
|
466
549
|
el('div', { class: 'strong' }, cl.title),
|
|
467
|
-
|
|
550
|
+
// Preview line: version count + last-updated date so the row isn't a bare
|
|
551
|
+
// title. (The latest version's note isn't in this response — listByJob maps
|
|
552
|
+
// without versions — so we fall back to the date per the design.)
|
|
553
|
+
el('div', { class: 'muted text-sm' },
|
|
554
|
+
t('jobs.cover.versions', { count: cl.version_count ?? 1 }),
|
|
555
|
+
cl.updated_at ? ` · ${fmtDate(cl.updated_at)}` : ''))),
|
|
468
556
|
icon('chevronRight', 'muted')))),
|
|
469
557
|
});
|
|
470
558
|
}
|
|
@@ -168,7 +168,6 @@ function Theme() {
|
|
|
168
168
|
/* ------------------------------------------------------------- 언어 / Language */
|
|
169
169
|
function LanguageSwitcher() {
|
|
170
170
|
// 테마 컴포넌트와 동일 패턴: localStorage 영속 + paint() + 변경 시 재페인트.
|
|
171
|
-
// 카탈로그가 채워지는 Phase 2부터 페이지 텍스트가 실제로 전환된다.
|
|
172
171
|
const options = [
|
|
173
172
|
{ value: 'ko', label: '한국어' },
|
|
174
173
|
{ value: 'en', label: 'English' },
|
|
@@ -188,8 +187,9 @@ function LanguageSwitcher() {
|
|
|
188
187
|
onLangChange(paint);
|
|
189
188
|
|
|
190
189
|
return Card({
|
|
190
|
+
// 제목은 의도적으로 양언어 병기 — 현재 언어를 못 읽는 사용자도 이 카드를 찾아야 한다.
|
|
191
191
|
title: '언어 / Language',
|
|
192
|
-
sub: '
|
|
192
|
+
sub: t('settings.lang.sub'),
|
|
193
193
|
body: row,
|
|
194
194
|
});
|
|
195
195
|
}
|
package/dist/public/styles.css
CHANGED
|
@@ -419,6 +419,9 @@ h1,h2,h3,h4 { margin: 0; font-weight: 650; letter-spacing: -0.01em; }
|
|
|
419
419
|
.pipefunnel__value { font-size: 24px; font-weight: 700; letter-spacing: -.02em; margin-top: 5px; line-height: 1.1; }
|
|
420
420
|
.pipefunnel__conv { font-size: 12px; color: var(--text-tertiary); margin-top: 2px; }
|
|
421
421
|
@media (max-width: 560px){ .pipefunnel { grid-template-columns: repeat(2, 1fr); } }
|
|
422
|
+
/* Home KPI tiles (the only .grid--4) read better as a 2×2 on phones than a single
|
|
423
|
+
full-width column; overrides the ≤900px collapse for this narrow range. */
|
|
424
|
+
@media (max-width: 560px){ .grid--4 { grid-template-columns: repeat(2, 1fr); } }
|
|
422
425
|
|
|
423
426
|
/* ----------------------------------------------------------------- check row (onboarding)
|
|
424
427
|
The ONE place setup guidance lives. Outcome rows auto-detect completion. */
|
|
@@ -577,3 +580,16 @@ h1,h2,h3,h4 { margin: 0; font-weight: 650; letter-spacing: -0.01em; }
|
|
|
577
580
|
.card__head .right{ margin-left: 0; width: 100%; justify-content: flex-start; }
|
|
578
581
|
.page-head{ flex-wrap: wrap; }
|
|
579
582
|
}
|
|
583
|
+
|
|
584
|
+
/* ---------- reduced motion ----------
|
|
585
|
+
전정기관 민감 사용자를 위해 OS 설정을 존중한다: 모달·토스트·오프캔버스의
|
|
586
|
+
등장 애니메이션과 hover 트랜지션을 사실상 즉시(0.01ms)로 줄인다.
|
|
587
|
+
(스켈레톤 shimmer 포함 — 반복 애니메이션이 가장 부담이 크다.) */
|
|
588
|
+
@media (prefers-reduced-motion: reduce) {
|
|
589
|
+
*, *::before, *::after {
|
|
590
|
+
animation-duration: 0.01ms !important;
|
|
591
|
+
animation-iteration-count: 1 !important;
|
|
592
|
+
transition-duration: 0.01ms !important;
|
|
593
|
+
scroll-behavior: auto !important;
|
|
594
|
+
}
|
|
595
|
+
}
|