careermate 0.1.1 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -10
- package/THIRD_PARTY_NOTICES.md +15 -0
- package/apps/mcp/src/index.ts +4 -5
- package/apps/web/demo/demo-shim.js +536 -0
- package/apps/web/demo/demo.css +75 -0
- package/apps/web/demo/seed.js +269 -0
- package/apps/web/public/app.js +34 -2
- package/apps/web/public/index.html +10 -2
- package/apps/web/public/lib.js +134 -22
- package/apps/web/public/pages/applications.js +2 -9
- package/apps/web/public/pages/documents.js +66 -50
- package/apps/web/public/pages/home.js +12 -11
- package/apps/web/public/pages/interview.js +9 -12
- package/apps/web/public/pages/jobs.js +39 -36
- package/apps/web/public/pages/profile.js +66 -86
- package/apps/web/public/pages/settings.js +15 -10
- package/apps/web/public/styles.css +56 -2
- package/apps/web/src/http.ts +31 -4
- package/apps/web/src/info.ts +3 -3
- package/apps/web/src/routes.ts +2 -1
- package/apps/web/src/server.ts +1 -4
- package/dist/doctor.mjs +72607 -611
- package/dist/install-page/.omc/state/agent-replay-55a58c0d-bb6e-43ea-8a05-4b6c93b80b34.jsonl +12 -0
- package/dist/install-page/.omc/state/idle-notif-cooldown.json +3 -0
- package/dist/install-page/.omc/state/sessions/55a58c0d-bb6e-43ea-8a05-4b6c93b80b34/mission-state.json +147 -0
- package/dist/install-page/.omc/state/sessions/55a58c0d-bb6e-43ea-8a05-4b6c93b80b34/pre-tool-advisory-throttle.json +22 -0
- package/dist/install-page/.omc/state/sessions/55a58c0d-bb6e-43ea-8a05-4b6c93b80b34/subagent-tracking-state.json +44 -0
- package/dist/install-page/index.html +31 -70
- package/dist/install-page/landing.js +54 -0
- package/dist/install-page/start.html +13 -36
- package/dist/install-page/style.css +34 -1
- package/dist/mcp.mjs +73936 -1976
- package/dist/migrate.mjs +17 -10
- package/dist/pdf.worker.min.mjs +28 -0
- package/dist/public/app.js +34 -2
- package/dist/public/index.html +10 -2
- package/dist/public/lib.js +134 -22
- package/dist/public/pages/applications.js +2 -9
- package/dist/public/pages/documents.js +66 -50
- package/dist/public/pages/home.js +12 -11
- package/dist/public/pages/interview.js +9 -12
- package/dist/public/pages/jobs.js +39 -36
- package/dist/public/pages/profile.js +66 -86
- package/dist/public/pages/settings.js +15 -10
- package/dist/public/styles.css +56 -2
- package/dist/web.mjs +39631 -244
- package/docs/ARCHITECTURE.md +5 -5
- package/docs/DECISIONS.md +1 -1
- package/docs/DEMO.md +3 -3
- package/docs/MCP_TOOLS.md +49 -9
- package/docs/ROADMAP.md +8 -7
- package/docs/START_WORKFLOW.md +13 -7
- package/docs/TODO.md +1 -1
- package/docs/WORKFLOWS.md +7 -5
- package/docs/superpowers/specs/2026-06-16-demo-dashboard-deploy-design.md +64 -0
- package/package.json +6 -2
- package/packages/db/src/paths.ts +11 -0
- package/packages/mcp-tools/src/bridge.ts +55 -32
- package/packages/mcp-tools/src/index.ts +9 -0
- package/packages/mcp-tools/src/tools.ts +193 -70
- package/packages/mcp-tools/src/update.ts +330 -0
- package/packages/parsers/src/documents.ts +521 -0
- package/packages/parsers/src/index.ts +8 -5
- package/packages/prompts/src/onboarding.ts +2 -2
- package/packages/shared/src/build-info.ts +22 -0
- package/packages/shared/src/index.ts +1 -0
- package/packages/shared/src/schemas.ts +24 -3
- package/packages/workflows/src/definitions.ts +4 -2
- package/scripts/build-demo.mjs +130 -0
- package/scripts/build-dist.mjs +20 -1
- package/scripts/doctor.ts +16 -3
package/apps/web/public/lib.js
CHANGED
|
@@ -10,6 +10,19 @@
|
|
|
10
10
|
|
|
11
11
|
/* --------------------------------------------------------------- DOM helper */
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Neutralize script-capable URL schemes before they reach an href/src. Career
|
|
15
|
+
* data (profile links, job URLs) is user-controlled and can arrive via the MCP
|
|
16
|
+
* path with no CSRF gate, so a stored `javascript:…` link would execute when
|
|
17
|
+
* clicked. http(s)/mailto and scheme-less/relative URLs pass through unchanged;
|
|
18
|
+
* any other explicit scheme is replaced with a harmless '#'.
|
|
19
|
+
*/
|
|
20
|
+
export function safeUrl(v) {
|
|
21
|
+
const s = String(v).trim();
|
|
22
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(s)) return /^(https?:|mailto:)/i.test(s) ? s : '#';
|
|
23
|
+
return s;
|
|
24
|
+
}
|
|
25
|
+
|
|
13
26
|
/**
|
|
14
27
|
* Create an element. `props` may include: class, id, type, value, placeholder,
|
|
15
28
|
* href, title, disabled, dataset:{}, style:{}, attrs:{}, and on* event handlers
|
|
@@ -27,7 +40,19 @@ export function el(tag, props = {}, ...children) {
|
|
|
27
40
|
else if (k === 'dataset') Object.assign(node.dataset, v);
|
|
28
41
|
else if (k === 'style') Object.assign(node.style, v);
|
|
29
42
|
else if (k === 'attrs') for (const [a, val] of Object.entries(v)) node.setAttribute(a, val);
|
|
43
|
+
else if (k === 'href' || k === 'src') node.setAttribute(k, safeUrl(v)); // block javascript:/data: URLs
|
|
30
44
|
else if (k.startsWith('on') && typeof v === 'function') node.addEventListener(k.slice(2).toLowerCase(), v);
|
|
45
|
+
else if (k === 'activate' && typeof v === 'function') {
|
|
46
|
+
// Make a non-native element behave like a button for keyboard + AT users:
|
|
47
|
+
// role, focusability, and Enter/Space activation. Use this instead of a bare
|
|
48
|
+
// onClick on clickable <div>/<span> so they aren't mouse-only dead zones.
|
|
49
|
+
if (!node.getAttribute('role')) node.setAttribute('role', 'button');
|
|
50
|
+
if (node.getAttribute('tabindex') == null) node.setAttribute('tabindex', '0');
|
|
51
|
+
node.addEventListener('click', v);
|
|
52
|
+
node.addEventListener('keydown', (e) => {
|
|
53
|
+
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); v(e); }
|
|
54
|
+
});
|
|
55
|
+
}
|
|
31
56
|
else if (k in node) { try { node[k] = v; } catch { node.setAttribute(k, v); } }
|
|
32
57
|
else node.setAttribute(k, v);
|
|
33
58
|
}
|
|
@@ -40,7 +65,6 @@ function appendChildren(node, children) {
|
|
|
40
65
|
node.append(c instanceof Node ? c : document.createTextNode(String(c)));
|
|
41
66
|
}
|
|
42
67
|
}
|
|
43
|
-
export function frag(...children) { const f = document.createDocumentFragment(); appendChildren(f, children); return f; }
|
|
44
68
|
export function clear(node) { while (node.firstChild) node.removeChild(node.firstChild); return node; }
|
|
45
69
|
export function mount(node, ...children) { clear(node); appendChildren(node, children); return node; }
|
|
46
70
|
|
|
@@ -94,19 +118,33 @@ const TOKEN = document.querySelector('meta[name="careermate-token"]')?.content |
|
|
|
94
118
|
|
|
95
119
|
/** Call the JSON API. Throws Error(message) on non-2xx. */
|
|
96
120
|
export async function api(method, path, body) {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
121
|
+
let res;
|
|
122
|
+
try {
|
|
123
|
+
res = await fetch(path, {
|
|
124
|
+
method,
|
|
125
|
+
headers: {
|
|
126
|
+
'Content-Type': 'application/json',
|
|
127
|
+
...(method !== 'GET' ? { 'x-careermate-token': TOKEN } : {}),
|
|
128
|
+
},
|
|
129
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
130
|
+
});
|
|
131
|
+
} catch {
|
|
132
|
+
// Connection failure (server stopped, machine asleep) — never surface "Failed to fetch".
|
|
133
|
+
throw new Error('서버에 연결하지 못했어요. 대시보드가 실행 중인지 확인한 뒤 다시 시도해 주세요.');
|
|
134
|
+
}
|
|
105
135
|
const text = await res.text();
|
|
106
|
-
|
|
107
|
-
if (
|
|
136
|
+
let data = {};
|
|
137
|
+
if (text) { try { data = JSON.parse(text); } catch { /* non-JSON error page */ } }
|
|
138
|
+
if (!res.ok) throw new Error(data.error || friendlyStatus(res.status));
|
|
108
139
|
return data;
|
|
109
140
|
}
|
|
141
|
+
// Friendly fallback when the server didn't supply a message — never show a raw status code.
|
|
142
|
+
function friendlyStatus(status) {
|
|
143
|
+
if (status === 404) return '요청한 항목을 찾을 수 없어요. 새로고침 후 다시 시도해 주세요.';
|
|
144
|
+
if (status === 403) return '권한 확인이 필요해요. 대시보드를 새로고침해 주세요.';
|
|
145
|
+
if (status >= 500) return '일시적인 오류가 발생했어요. 잠시 후 다시 시도해 주세요.';
|
|
146
|
+
return '요청을 처리하지 못했어요. 입력을 확인하고 다시 시도해 주세요.';
|
|
147
|
+
}
|
|
110
148
|
export const get = (p) => api('GET', p);
|
|
111
149
|
export const post = (p, b) => api('POST', p, b);
|
|
112
150
|
export const put = (p, b) => api('PUT', p, b);
|
|
@@ -134,6 +172,22 @@ export function scoreClass(score) {
|
|
|
134
172
|
if (score >= 50) return 'score-mid';
|
|
135
173
|
return 'score-weak';
|
|
136
174
|
}
|
|
175
|
+
/** Textarea (newline-separated) → trimmed array, empties dropped. */
|
|
176
|
+
export function linesToArray(str) { return String(str || '').split('\n').map((s) => s.trim()).filter(Boolean); }
|
|
177
|
+
/** CSV ("a, b, c") → trimmed array, empties dropped. */
|
|
178
|
+
export function csvToArray(str) { return String(str || '').split(',').map((s) => s.trim()).filter(Boolean); }
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Application status code → saturated theme colour. Single source of truth so a
|
|
182
|
+
* colour means the same pipeline stage everywhere (home pipeline bar/dots,
|
|
183
|
+
* applications board dots, badges). MUST track the badge--{status} hues in CSS.
|
|
184
|
+
*/
|
|
185
|
+
export const STATUS_COLOR = {
|
|
186
|
+
draft: 'var(--slate)', planned: 'var(--blue)', applied: 'var(--violet)',
|
|
187
|
+
document_passed: 'var(--teal)', interview: 'var(--amber)',
|
|
188
|
+
final_passed: 'var(--green)', on_hold: 'var(--slate)', rejected: 'var(--red)',
|
|
189
|
+
};
|
|
190
|
+
export const statusColor = (code) => STATUS_COLOR[code] || 'var(--text-tertiary)';
|
|
137
191
|
|
|
138
192
|
/* ---------------------------------------------------------------- routing */
|
|
139
193
|
export function navigate(hash) { location.hash = hash.startsWith('#') ? hash : '#' + hash; }
|
|
@@ -149,17 +203,50 @@ export function Btn(label, { icon: ic, variant = '', sm, onClick, type, disabled
|
|
|
149
203
|
export function IconBtn(name, { onClick, title, variant = 'ghost' } = {}) {
|
|
150
204
|
return el('button', { class: `btn btn--${variant} icon-btn`, onClick, title, attrs: { 'aria-label': title || name } }, icon(name));
|
|
151
205
|
}
|
|
206
|
+
/**
|
|
207
|
+
* A footer button for async saves. While `action()` runs the button is disabled
|
|
208
|
+
* (so a slow local round-trip can't be double-clicked into duplicate records),
|
|
209
|
+
* and if it throws the message is shown in the modal's persistent `.modal__error`
|
|
210
|
+
* slot (not a transient toast) so the user sees why the save failed and can fix
|
|
211
|
+
* and retry. `action` should THROW on failure (validation or API) and is expected
|
|
212
|
+
* to call close()/reload() itself on success.
|
|
213
|
+
*/
|
|
214
|
+
export function SubmitBtn(label, action, { variant = 'primary', icon: ic } = {}) {
|
|
215
|
+
let busy = false;
|
|
216
|
+
const btn = Btn(label, {
|
|
217
|
+
variant,
|
|
218
|
+
icon: ic,
|
|
219
|
+
onClick: async () => {
|
|
220
|
+
if (busy) return;
|
|
221
|
+
busy = true;
|
|
222
|
+
btn.disabled = true;
|
|
223
|
+
const slot = btn.closest('.modal')?.querySelector('.modal__error');
|
|
224
|
+
if (slot) { slot.hidden = true; slot.textContent = ''; }
|
|
225
|
+
try {
|
|
226
|
+
await action();
|
|
227
|
+
} catch (e) {
|
|
228
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
229
|
+
if (slot) { slot.textContent = msg; slot.hidden = false; slot.scrollIntoView?.({ block: 'nearest' }); }
|
|
230
|
+
else toastError(e);
|
|
231
|
+
} finally {
|
|
232
|
+
busy = false;
|
|
233
|
+
btn.disabled = false;
|
|
234
|
+
}
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
return btn;
|
|
238
|
+
}
|
|
152
239
|
export function Card({ title, sub, actions, body, clickable, onClick } = {}) {
|
|
153
240
|
const head = (title || actions) && el('div', { class: 'card__head' },
|
|
154
241
|
title && el('h3', {}, title),
|
|
155
242
|
sub && el('span', { class: 'sub' }, sub),
|
|
156
243
|
actions && el('div', { class: 'right' }, ...[].concat(actions)),
|
|
157
244
|
);
|
|
158
|
-
return el('div', { class: `card${clickable ? ' is-clickable' : ''}`, onClick },
|
|
245
|
+
return el('div', { class: `card${clickable ? ' is-clickable' : ''}`, activate: onClick },
|
|
159
246
|
head, el('div', { class: 'card__body' }, ...[].concat(body || [])));
|
|
160
247
|
}
|
|
161
|
-
export function Stat({ label, value, hint, iconName }) {
|
|
162
|
-
return el('div', { class: 'stat' },
|
|
248
|
+
export function Stat({ label, value, hint, iconName, onClick }) {
|
|
249
|
+
return el('div', { class: 'stat', activate: onClick },
|
|
163
250
|
el('div', { class: 'stat__label' }, iconName && icon(iconName), label),
|
|
164
251
|
el('div', { class: 'stat__value tnum' }, value),
|
|
165
252
|
hint && el('div', { class: 'stat__hint' }, hint),
|
|
@@ -178,7 +265,7 @@ export function Chips(items, { accent } = {}) {
|
|
|
178
265
|
}
|
|
179
266
|
/** Dense list row: leading slot → title + muted sub → right-aligned trailing. */
|
|
180
267
|
export function ListRow({ leading, title, sub, trailing, onClick } = {}) {
|
|
181
|
-
return el('div', { class: `list-row${onClick ? ' is-clickable' : ''}`, onClick },
|
|
268
|
+
return el('div', { class: `list-row${onClick ? ' is-clickable' : ''}`, activate: onClick },
|
|
182
269
|
leading && el('div', { class: 'list-row__lead' }, leading),
|
|
183
270
|
el('div', { class: 'list-row__main' },
|
|
184
271
|
el('div', { class: 'list-row__title' }, title),
|
|
@@ -187,9 +274,11 @@ export function ListRow({ leading, title, sub, trailing, onClick } = {}) {
|
|
|
187
274
|
}
|
|
188
275
|
/** Onboarding checklist row: hollow dot (todo, links via onClick) or green check (done, struck through). */
|
|
189
276
|
export function CheckRow({ done, label, onClick } = {}) {
|
|
190
|
-
|
|
277
|
+
// Whole row is the activation target (bigger hit area + keyboard) when it's a todo.
|
|
278
|
+
const interactive = !done && typeof onClick === 'function';
|
|
279
|
+
return el('div', { class: `check-row${done ? ' is-done' : ''}`, activate: interactive ? onClick : undefined },
|
|
191
280
|
done ? icon('check', 'check-row__icon') : el('span', { class: 'check-row__dot' }),
|
|
192
|
-
el('span', { class: `check-row__label${
|
|
281
|
+
el('span', { class: `check-row__label${interactive ? ' is-link' : ''}` }, label));
|
|
193
282
|
}
|
|
194
283
|
export function Field(label, control, hint) {
|
|
195
284
|
return el('div', { class: 'field' }, label && el('label', {}, label), control, hint && el('div', { class: 'hint' }, hint));
|
|
@@ -222,6 +311,8 @@ export function toastOk(msg) { toast(msg, { type: 'success' }); }
|
|
|
222
311
|
|
|
223
312
|
/* ------------------------------------------------------------------ modal */
|
|
224
313
|
let modalRoot;
|
|
314
|
+
let modalReturnFocus = null;
|
|
315
|
+
const MODAL_FOCUSABLE = 'a[href],button:not([disabled]),input:not([disabled]),textarea:not([disabled]),select:not([disabled]),[tabindex]:not([tabindex="-1"])';
|
|
225
316
|
function ensureModalRoot() {
|
|
226
317
|
if (!modalRoot) {
|
|
227
318
|
modalRoot = el('div', { class: 'modal-root' });
|
|
@@ -230,7 +321,11 @@ function ensureModalRoot() {
|
|
|
230
321
|
}
|
|
231
322
|
return modalRoot;
|
|
232
323
|
}
|
|
233
|
-
export function closeModal() {
|
|
324
|
+
export function closeModal() {
|
|
325
|
+
if (modalRoot) { modalRoot.classList.remove('is-open'); clear(modalRoot); }
|
|
326
|
+
// Return focus to whatever opened the modal so keyboard users aren't dumped at the page top.
|
|
327
|
+
if (modalReturnFocus) { try { modalReturnFocus.focus(); } catch { /* element gone */ } modalReturnFocus = null; }
|
|
328
|
+
}
|
|
234
329
|
/**
|
|
235
330
|
* Open a modal. `render(close)` returns the modal body content. Provide title,
|
|
236
331
|
* optional footer buttons via `footer(close)`. Returns nothing; call closeModal().
|
|
@@ -238,18 +333,36 @@ export function closeModal() { if (modalRoot) { modalRoot.classList.remove('is-o
|
|
|
238
333
|
export function openModal({ title, body, footer, size }) {
|
|
239
334
|
const root = ensureModalRoot();
|
|
240
335
|
const close = closeModal;
|
|
336
|
+
modalReturnFocus = document.activeElement; // restored by closeModal
|
|
241
337
|
const closeBtn = IconBtn('plus', { title: '닫기', onClick: close }); // rotated to look like ×
|
|
242
338
|
closeBtn.style.transform = 'rotate(45deg)';
|
|
243
|
-
const modal = el('div', {
|
|
244
|
-
|
|
339
|
+
const modal = el('div', {
|
|
340
|
+
class: `modal${size === 'lg' ? ' modal--lg' : ''}`,
|
|
341
|
+
attrs: { role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'modal-title' },
|
|
342
|
+
},
|
|
343
|
+
el('div', { class: 'modal__head' }, el('h3', { id: 'modal-title' }, title), el('div', { class: 'topbar__spacer' }), closeBtn),
|
|
245
344
|
);
|
|
345
|
+
// Focus trap: keep Tab / Shift+Tab inside the dialog instead of leaking to the page behind.
|
|
346
|
+
modal.addEventListener('keydown', (e) => {
|
|
347
|
+
if (e.key !== 'Tab') return;
|
|
348
|
+
const f = [...modal.querySelectorAll(MODAL_FOCUSABLE)].filter((x) => x.offsetParent !== null);
|
|
349
|
+
if (!f.length) return;
|
|
350
|
+
const first = f[0], last = f[f.length - 1];
|
|
351
|
+
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
|
|
352
|
+
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
|
|
353
|
+
});
|
|
246
354
|
const bodyNode = el('div', { class: 'modal__body' });
|
|
247
355
|
appendChildren(bodyNode, [typeof body === 'function' ? body(close) : body]);
|
|
248
356
|
modal.append(bodyNode);
|
|
357
|
+
// Persistent inline error slot so a failed save shows a clear reason instead of
|
|
358
|
+
// relying on a 3.8s toast. Placed between body and footer (always visible). Any
|
|
359
|
+
// SubmitBtn inside the modal writes here — including inline editors in bodies
|
|
360
|
+
// that have no footer; see SubmitBtn.
|
|
361
|
+
modal.append(el('div', { class: 'modal__error', hidden: true, attrs: { role: 'alert', 'aria-live': 'assertive' } }));
|
|
249
362
|
if (footer) modal.append(el('div', { class: 'modal__foot' }, ...[].concat(footer(close))));
|
|
250
363
|
mount(root, el('div', { class: 'modal__scrim', onClick: close }), modal);
|
|
251
364
|
root.classList.add('is-open');
|
|
252
|
-
setTimeout(() => bodyNode.querySelector('input,textarea,select
|
|
365
|
+
setTimeout(() => (bodyNode.querySelector('input,textarea,select') || modal.querySelector(MODAL_FOCUSABLE))?.focus(), 30);
|
|
253
366
|
}
|
|
254
367
|
/** Confirmation dialog. Returns a Promise<boolean>. */
|
|
255
368
|
export function confirmDialog({ title = '확인', message, confirmLabel = '확인', danger } = {}) {
|
|
@@ -275,7 +388,6 @@ export function downloadUrl(url) {
|
|
|
275
388
|
const a = el('a', { href: url, download: '' });
|
|
276
389
|
document.body.append(a); a.click(); a.remove();
|
|
277
390
|
}
|
|
278
|
-
export function debounce(fn, ms = 250) { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; }
|
|
279
391
|
|
|
280
392
|
/** Shared meta (statuses, document kinds) loaded once. */
|
|
281
393
|
let _meta = null;
|
|
@@ -2,16 +2,9 @@
|
|
|
2
2
|
// (no horizontal scroll). Change a status inline to move an item between groups.
|
|
3
3
|
import {
|
|
4
4
|
el, get, put, navigate, EmptyState, Btn, ListRow,
|
|
5
|
-
Select, scoreClass, mount, toast, toastOk, toastError, meta,
|
|
5
|
+
Select, scoreClass, statusColor, mount, toast, toastOk, toastError, meta,
|
|
6
6
|
} from '/lib.js';
|
|
7
7
|
|
|
8
|
-
// status code → theme colour (matches the badge--{status} hues)
|
|
9
|
-
const STATUS_COLOR = {
|
|
10
|
-
draft: 'var(--slate)', planned: 'var(--blue)', applied: 'var(--violet)',
|
|
11
|
-
document_passed: 'var(--teal)', interview: 'var(--amber)',
|
|
12
|
-
final_passed: 'var(--green)', on_hold: 'var(--slate)', rejected: 'var(--red)',
|
|
13
|
-
};
|
|
14
|
-
|
|
15
8
|
// deadline(YYYY-MM-DD) → "마감 D-7" / "마감 D-day" / "마감 지남" (null if absent/invalid)
|
|
16
9
|
function ddayLabel(deadline) {
|
|
17
10
|
if (!deadline) return null;
|
|
@@ -63,7 +56,7 @@ export async function render(ctx) {
|
|
|
63
56
|
function StatusGroup(code, apps) {
|
|
64
57
|
return el('div', {},
|
|
65
58
|
el('div', { class: 'app-group__head' },
|
|
66
|
-
el('span', { class: 'app-group__dot', style: { background:
|
|
59
|
+
el('span', { class: 'app-group__dot', style: { background: statusColor(code) } }),
|
|
67
60
|
el('span', { class: 'app-group__title' }, labelByCode[code] || code),
|
|
68
61
|
el('span', { class: 'app-group__count tnum' }, `${apps.length}건`)),
|
|
69
62
|
el('div', {}, ...apps.map(AppRow)));
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Documents — cover letters (with version history) and résumés/other documents.
|
|
2
2
|
// XSS-safe: all DB/user text rendered via el() textContent / .doc-preview (never innerHTML).
|
|
3
3
|
import {
|
|
4
|
-
el, get, post, put, del, Card, Badge, Btn, IconBtn, EmptyState,
|
|
4
|
+
el, get, post, put, del, Card, Badge, Btn, IconBtn, SubmitBtn, EmptyState,
|
|
5
5
|
Field, Input, Textarea, Select, openModal, confirmDialog,
|
|
6
6
|
toastOk, toastError, copyText, downloadUrl, fmtRelative, mount, meta,
|
|
7
7
|
} from '/lib.js';
|
|
@@ -9,7 +9,10 @@ import {
|
|
|
9
9
|
const SOURCE_LABELS = { manual: '직접 입력', upload: '파일 업로드', ai: 'AI 생성', edit: '직접 수정' };
|
|
10
10
|
const sourceLabel = (s) => SOURCE_LABELS[s] || s || '';
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
// Module-scoped on purpose: remember the last-viewed tab ('cover' | 'docs') across
|
|
13
|
+
// navigations so returning to Documents reopens where the user left off. The search
|
|
14
|
+
// query is intentionally NOT persisted (it resets per visit).
|
|
15
|
+
let tab = 'cover';
|
|
13
16
|
|
|
14
17
|
export async function render(ctx) {
|
|
15
18
|
const root = el('div', { class: 'stack-4' });
|
|
@@ -101,7 +104,7 @@ async function CoverLettersTab(reload) {
|
|
|
101
104
|
function CoverRow(cl, reload) {
|
|
102
105
|
const metaRow = el('div', { class: 'flex gap-2 wrap', style: { marginTop: '4px' } },
|
|
103
106
|
el('span', { class: 'chip' }, `버전 ${cl.version_count}개`),
|
|
104
|
-
cl.is_primary ? Badge('
|
|
107
|
+
cl.is_primary ? Badge('accent', '대표') : null,
|
|
105
108
|
);
|
|
106
109
|
|
|
107
110
|
const actions = el('div', { class: 'flex gap-2 wrap' },
|
|
@@ -129,7 +132,7 @@ function CoverRow(cl, reload) {
|
|
|
129
132
|
async function setPrimaryCover(id, reload) {
|
|
130
133
|
try {
|
|
131
134
|
await put(`/api/cover-letters/${id}/primary`, {});
|
|
132
|
-
toastOk('대표 자기소개서로
|
|
135
|
+
toastOk('대표 자기소개서로 지정했어요.');
|
|
133
136
|
await reload();
|
|
134
137
|
} catch (err) { toastError(err); }
|
|
135
138
|
}
|
|
@@ -143,45 +146,56 @@ async function removeCover(cl, reload) {
|
|
|
143
146
|
if (!ok) return;
|
|
144
147
|
try {
|
|
145
148
|
await del(`/api/cover-letters/${cl.id}`);
|
|
146
|
-
toastOk('자기소개서를
|
|
149
|
+
toastOk('자기소개서를 삭제했어요.');
|
|
147
150
|
await reload();
|
|
148
151
|
} catch (err) { toastError(err); }
|
|
149
152
|
}
|
|
150
153
|
|
|
151
|
-
function openCoverCreate(reload) {
|
|
154
|
+
async function openCoverCreate(reload) {
|
|
155
|
+
// Let the user PICK the job to link instead of typing an internal ID they've never seen.
|
|
156
|
+
let jobs = [];
|
|
157
|
+
try { ({ jobs } = await get('/api/jobs')); } catch { /* dropdown falls back to "연결 안 함" */ }
|
|
152
158
|
const title = Input({ placeholder: '예: 백엔드 개발자 자기소개서', attrs: { maxlength: '200' } });
|
|
153
159
|
const content = Textarea({ placeholder: '내용을 붙여넣으세요. 비워 두면 빈 자기소개서가 만들어집니다.', style: { minHeight: '200px' } });
|
|
154
|
-
const
|
|
160
|
+
const jobPick = Select([
|
|
161
|
+
{ value: '', label: '연결 안 함', selected: true },
|
|
162
|
+
...jobs.map((j) => ({ value: j.id, label: `${j.company} · ${j.position}` })),
|
|
163
|
+
]);
|
|
155
164
|
|
|
156
165
|
openModal({
|
|
157
166
|
title: '새 자기소개서',
|
|
158
167
|
size: 'lg',
|
|
159
168
|
body: el('div', { class: 'stack-3' },
|
|
160
169
|
Field('제목 (필수)', title),
|
|
161
|
-
Field('내용 (선택)', content, '내용을 입력하면
|
|
162
|
-
Field('공고
|
|
170
|
+
Field('내용 (선택)', content, '내용을 입력하면 첫 버전으로 저장됩니다.'),
|
|
171
|
+
Field('연결할 공고 (선택)', jobPick,
|
|
172
|
+
jobs.length ? '특정 공고에 연결하면 그 공고 화면에서 함께 보여요.' : '저장된 공고가 아직 없어요.'),
|
|
163
173
|
),
|
|
164
174
|
footer: (close) => [
|
|
165
175
|
Btn('취소', { onClick: close }),
|
|
166
|
-
|
|
176
|
+
SubmitBtn('자기소개서 저장', async () => {
|
|
167
177
|
const t = title.value.trim();
|
|
168
|
-
if (!t) {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
} catch (err) { toastError(err); }
|
|
178
|
-
} }),
|
|
178
|
+
if (!t) { title.focus(); throw new Error('제목을 입력해 주세요.'); }
|
|
179
|
+
const body = { title: t, source: 'manual' };
|
|
180
|
+
if (content.value.trim()) body.content = content.value;
|
|
181
|
+
if (jobPick.value) body.job_id = jobPick.value;
|
|
182
|
+
await post('/api/cover-letters', body);
|
|
183
|
+
toastOk('자기소개서를 추가했어요.');
|
|
184
|
+
close();
|
|
185
|
+
await reload();
|
|
186
|
+
}),
|
|
179
187
|
],
|
|
180
188
|
});
|
|
181
189
|
}
|
|
182
190
|
|
|
183
191
|
async function openCoverDetail(id, reload) {
|
|
184
|
-
let data
|
|
192
|
+
let data;
|
|
193
|
+
try {
|
|
194
|
+
data = (await get(`/api/cover-letters/${id}`)).cover_letter;
|
|
195
|
+
} catch (err) {
|
|
196
|
+
toastError(err);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
185
199
|
|
|
186
200
|
openModal({
|
|
187
201
|
title: data.title,
|
|
@@ -210,7 +224,7 @@ function CoverDetailBody(cl, { refresh }) {
|
|
|
210
224
|
wrap.append(el('div', { class: 'flex between wrap gap-2' },
|
|
211
225
|
el('div', { class: 'flex gap-2 wrap' },
|
|
212
226
|
el('span', { class: 'chip' }, `버전 ${cl.version_count}개`),
|
|
213
|
-
cl.is_primary ? Badge('
|
|
227
|
+
cl.is_primary ? Badge('accent', '대표') : null,
|
|
214
228
|
),
|
|
215
229
|
el('div', { class: 'flex gap-2 wrap' },
|
|
216
230
|
Btn('MD 내보내기', { sm: true, variant: 'ghost', icon: 'download', onClick: () => downloadUrl(`/api/export/cover-letter/${cl.id}?format=md`) }),
|
|
@@ -277,7 +291,7 @@ function VersionItem(cl, v, { isCurrent, setPreview, refresh }) {
|
|
|
277
291
|
async function makeCurrent(id, versionId, refresh) {
|
|
278
292
|
try {
|
|
279
293
|
await put(`/api/cover-letters/${id}/current-version`, { version_id: versionId });
|
|
280
|
-
toastOk('현재 버전을
|
|
294
|
+
toastOk('현재 버전을 변경했어요.');
|
|
281
295
|
await refresh();
|
|
282
296
|
} catch (err) { toastError(err); }
|
|
283
297
|
}
|
|
@@ -292,16 +306,14 @@ function EditNewVersion(cl, refresh) {
|
|
|
292
306
|
Field('내용', content),
|
|
293
307
|
Field('변경 메모 (선택)', note),
|
|
294
308
|
el('div', { class: 'flex gap-2' },
|
|
295
|
-
|
|
296
|
-
if (!content.value.trim()) {
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
} catch (err) { toastError(err); }
|
|
304
|
-
} }),
|
|
309
|
+
SubmitBtn('새 버전으로 저장', async () => {
|
|
310
|
+
if (!content.value.trim()) { content.focus(); throw new Error('내용을 입력해 주세요.'); }
|
|
311
|
+
await post(`/api/cover-letters/${cl.id}/versions`, {
|
|
312
|
+
content: content.value, note: note.value.trim() || undefined, source: 'edit',
|
|
313
|
+
});
|
|
314
|
+
toastOk('새 버전을 저장했어요.');
|
|
315
|
+
await refresh();
|
|
316
|
+
}, { icon: 'check' }),
|
|
305
317
|
Btn('취소', { variant: 'ghost', onClick: () => { editor.style.display = 'none'; toggle.style.display = ''; } }),
|
|
306
318
|
),
|
|
307
319
|
);
|
|
@@ -344,7 +356,7 @@ function DocRow(doc, kindLabels, m, reload) {
|
|
|
344
356
|
el('div', { class: 'flex gap-2 wrap center' },
|
|
345
357
|
el('span', { class: 'strong' }, doc.title),
|
|
346
358
|
el('span', { class: 'chip' }, kindLabels[doc.kind] || doc.kind),
|
|
347
|
-
doc.is_primary ? Badge('
|
|
359
|
+
doc.is_primary ? Badge('accent', '대표') : null,
|
|
348
360
|
),
|
|
349
361
|
el('span', { class: 'muted text-sm' }, fmtRelative(doc.updated_at)),
|
|
350
362
|
);
|
|
@@ -363,7 +375,13 @@ function DocRow(doc, kindLabels, m, reload) {
|
|
|
363
375
|
}
|
|
364
376
|
|
|
365
377
|
async function openDocDetail(id, kindLabels, m, reload) {
|
|
366
|
-
|
|
378
|
+
let doc;
|
|
379
|
+
try {
|
|
380
|
+
doc = (await get(`/api/documents/${id}`)).document;
|
|
381
|
+
} catch (err) {
|
|
382
|
+
toastError(err);
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
367
385
|
|
|
368
386
|
openModal({
|
|
369
387
|
title: doc.title,
|
|
@@ -371,7 +389,7 @@ async function openDocDetail(id, kindLabels, m, reload) {
|
|
|
371
389
|
body: el('div', { class: 'stack-3' },
|
|
372
390
|
el('div', { class: 'flex gap-2 wrap center' },
|
|
373
391
|
el('span', { class: 'chip' }, kindLabels[doc.kind] || doc.kind),
|
|
374
|
-
doc.is_primary ? Badge('
|
|
392
|
+
doc.is_primary ? Badge('accent', '대표') : null,
|
|
375
393
|
el('span', { class: 'chip' }, sourceLabel(doc.source)),
|
|
376
394
|
el('span', { class: 'muted text-sm' }, `수정 ${fmtRelative(doc.updated_at)}`),
|
|
377
395
|
),
|
|
@@ -383,7 +401,7 @@ async function openDocDetail(id, kindLabels, m, reload) {
|
|
|
383
401
|
Btn('삭제', { variant: 'danger', icon: 'trash', onClick: async () => {
|
|
384
402
|
const ok = await confirmDialog({ title: '문서 삭제', message: `"${doc.title}"을(를) 삭제할까요?`, confirmLabel: '삭제', danger: true });
|
|
385
403
|
if (!ok) return;
|
|
386
|
-
try { await del(`/api/documents/${doc.id}`); toastOk('문서를
|
|
404
|
+
try { await del(`/api/documents/${doc.id}`); toastOk('문서를 삭제했어요.'); close(); await reload(); }
|
|
387
405
|
catch (err) { toastError(err); }
|
|
388
406
|
} }),
|
|
389
407
|
Btn('수정', { variant: 'primary', icon: 'edit', onClick: () => { close(); openDocEdit(doc, m, reload); } }),
|
|
@@ -392,7 +410,7 @@ async function openDocDetail(id, kindLabels, m, reload) {
|
|
|
392
410
|
}
|
|
393
411
|
|
|
394
412
|
function openDocCreate(reload) {
|
|
395
|
-
meta().then((m) => docForm(null, m, reload));
|
|
413
|
+
meta().then((m) => docForm(null, m, reload)).catch(toastError);
|
|
396
414
|
}
|
|
397
415
|
|
|
398
416
|
function openDocEdit(doc, m, reload) {
|
|
@@ -418,19 +436,17 @@ function docForm(doc, m, reload) {
|
|
|
418
436
|
),
|
|
419
437
|
footer: (close) => [
|
|
420
438
|
Btn('취소', { onClick: close }),
|
|
421
|
-
|
|
439
|
+
SubmitBtn('문서 저장', async () => {
|
|
422
440
|
const t = title.value.trim();
|
|
423
|
-
if (!t) {
|
|
424
|
-
if (!content.value.trim()) {
|
|
441
|
+
if (!t) { title.focus(); throw new Error('제목을 입력해 주세요.'); }
|
|
442
|
+
if (!content.value.trim()) { content.focus(); throw new Error('내용을 입력해 주세요.'); }
|
|
425
443
|
const body = { kind: kind.value, title: t, content: content.value, is_primary: primary.checked };
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
} catch (err) { toastError(err); }
|
|
433
|
-
} }),
|
|
444
|
+
if (doc) await put(`/api/documents/${doc.id}`, body);
|
|
445
|
+
else await post('/api/documents', { ...body, source: 'manual' });
|
|
446
|
+
toastOk(doc ? '문서를 수정했어요.' : '문서를 추가했어요.');
|
|
447
|
+
close();
|
|
448
|
+
await reload();
|
|
449
|
+
}),
|
|
434
450
|
],
|
|
435
451
|
});
|
|
436
452
|
}
|
|
@@ -5,16 +5,9 @@
|
|
|
5
5
|
// the same "AI에게 보내면 저장됩니다" instruction across surfaces.
|
|
6
6
|
import {
|
|
7
7
|
el, get, icon, navigate, Card, Badge, Btn, EmptyState, ListRow, CheckRow,
|
|
8
|
-
Stat, PageHead, fmtDate, fmtRelative, scoreClass, mount,
|
|
8
|
+
Stat, PageHead, fmtDate, fmtRelative, scoreClass, STATUS_COLOR, statusColor, mount,
|
|
9
9
|
} from '/lib.js';
|
|
10
10
|
|
|
11
|
-
// status code → saturated theme colour. MUST track the badge--{status} hues so a
|
|
12
|
-
// colour means the same stage in the pipeline bar, the badges, and the board.
|
|
13
|
-
const STATUS_COLOR = {
|
|
14
|
-
draft: 'var(--slate)', planned: 'var(--blue)', applied: 'var(--violet)',
|
|
15
|
-
document_passed: 'var(--teal)', interview: 'var(--amber)',
|
|
16
|
-
final_passed: 'var(--green)', on_hold: 'var(--slate)', rejected: 'var(--red)',
|
|
17
|
-
};
|
|
18
11
|
const PIPE_ORDER = ['draft', 'planned', 'applied', 'document_passed', 'interview', 'final_passed', 'on_hold', 'rejected'];
|
|
19
12
|
|
|
20
13
|
// activity type → icon + theme hue (soft tinted chip + coloured stroke)
|
|
@@ -84,6 +77,15 @@ function GettingStarted(o) {
|
|
|
84
77
|
title: '시작하기',
|
|
85
78
|
sub: `프로필 완성도 ${o.profile_completeness}%`,
|
|
86
79
|
body: [
|
|
80
|
+
// The one place (per DESIGN_GUIDE rule 4) that teaches the core mental model:
|
|
81
|
+
// CareerMate stores; your own AI does the thinking. Without this, first-run users
|
|
82
|
+
// read the empty dashboard as a manual CRUD form and never find the real workflow.
|
|
83
|
+
el('div', { class: 'callout mb-3' },
|
|
84
|
+
icon('info'),
|
|
85
|
+
el('div', {},
|
|
86
|
+
el('div', { class: 'callout__title' }, '두뇌는 당신의 AI, 보관은 CareerMate'),
|
|
87
|
+
el('div', { class: 'callout__body' },
|
|
88
|
+
'공고 분석·자기소개서·면접 준비는 평소 쓰던 AI에게 말로 시키면, 결과가 이 대시보드에 쌓여요.'))),
|
|
87
89
|
el('div', { class: 'progress' }, el('div', { class: 'progress__bar', style: { width: `${o.profile_completeness}%` } })),
|
|
88
90
|
el('p', { class: 'text-secondary text-sm', style: { margin: '12px 0 8px' } },
|
|
89
91
|
'완성할수록 AI의 적합도 분석과 자기소개서 품질이 좋아져요.'),
|
|
@@ -94,9 +96,8 @@ function GettingStarted(o) {
|
|
|
94
96
|
|
|
95
97
|
/* ----------------------------------------------- KPI summary tiles (at-a-glance) */
|
|
96
98
|
function StatTile(label, value, iconName, to) {
|
|
97
|
-
const tile = Stat({ label, value, iconName });
|
|
99
|
+
const tile = Stat({ label, value, iconName, onClick: () => navigate(to) });
|
|
98
100
|
tile.classList.add('is-clickable');
|
|
99
|
-
tile.addEventListener('click', () => navigate(to));
|
|
100
101
|
return tile;
|
|
101
102
|
}
|
|
102
103
|
function StatRow(counts) {
|
|
@@ -162,7 +163,7 @@ function Pipeline(breakdown) {
|
|
|
162
163
|
|
|
163
164
|
/* ----------------------------------------------- action lane (what to do next) */
|
|
164
165
|
function statusDot(status) {
|
|
165
|
-
return el('span', { class: 'list-row__dot', style: { background:
|
|
166
|
+
return el('span', { class: 'list-row__dot', style: { background: statusColor(status) } });
|
|
166
167
|
}
|
|
167
168
|
|
|
168
169
|
function ActionLane(interviewTodo, inProgress) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Interview — preparation materials for jobs that reached 서류 합격 or beyond.
|
|
2
2
|
import {
|
|
3
|
-
el, get, put, icon, navigate, Card, Badge, Btn, EmptyState, Field, Input,
|
|
4
|
-
Textarea, openModal, closeModal, toastOk,
|
|
3
|
+
el, get, put, icon, navigate, Card, Badge, Btn, SubmitBtn, EmptyState, Field, Input,
|
|
4
|
+
Textarea, openModal, closeModal, toastOk, copyText,
|
|
5
5
|
downloadUrl, mount,
|
|
6
6
|
} from '/lib.js';
|
|
7
7
|
|
|
@@ -200,7 +200,7 @@ export async function render(ctx) {
|
|
|
200
200
|
body: formBody,
|
|
201
201
|
footer: (close) => [
|
|
202
202
|
Btn('취소', { onClick: close }),
|
|
203
|
-
|
|
203
|
+
SubmitBtn('면접 준비 저장', () => save(jobId)),
|
|
204
204
|
],
|
|
205
205
|
});
|
|
206
206
|
|
|
@@ -216,15 +216,12 @@ export async function render(ctx) {
|
|
|
216
216
|
self_introduction: introInput.value,
|
|
217
217
|
notes: notesInput.value,
|
|
218
218
|
};
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
} catch (err) {
|
|
226
|
-
toastError(err);
|
|
227
|
-
}
|
|
219
|
+
// Throws on failure so the SubmitBtn surfaces it inline.
|
|
220
|
+
await put(`/api/jobs/${id}/interview`, payload);
|
|
221
|
+
closeModal();
|
|
222
|
+
toastOk('면접 준비를 저장했어요.');
|
|
223
|
+
await ctx.refreshNav();
|
|
224
|
+
await render(ctx);
|
|
228
225
|
}
|
|
229
226
|
}
|
|
230
227
|
}
|