현재 작업 전체 반영
This commit is contained in:
parent
5560638e54
commit
c0dddab594
85 changed files with 11322 additions and 539 deletions
|
|
@ -6,8 +6,13 @@ import {
|
|||
personaReviewApi,
|
||||
teacherApi,
|
||||
type PersonaReviewAction,
|
||||
type PersonaDraftDetail,
|
||||
type PersonaDraftPayload,
|
||||
type PersonaReviewStatus,
|
||||
type PersonaReviewSummary,
|
||||
type TeacherLearnerGrowth,
|
||||
type TeacherGrowthPoint,
|
||||
type TeacherSafetyAlert,
|
||||
type TeacherDashboardResponse,
|
||||
} from "../lib/api";
|
||||
|
||||
|
|
@ -25,6 +30,24 @@ function formatDateTime(value: string | null): string {
|
|||
});
|
||||
}
|
||||
|
||||
function formatScore(value: number | null | undefined): string {
|
||||
if (typeof value !== "number" || Number.isNaN(value)) return "평가 부족";
|
||||
return `${Math.round(value * 100)}%`;
|
||||
}
|
||||
|
||||
function formatDelta(value: number | null | undefined): string {
|
||||
if (typeof value !== "number" || Number.isNaN(value)) return "변화 부족";
|
||||
const sign = value > 0 ? "+" : "";
|
||||
return `${sign}${Math.round(value * 100)}%p`;
|
||||
}
|
||||
|
||||
function trendLabel(value: string): string {
|
||||
if (value === "up") return "상승";
|
||||
if (value === "down") return "하락";
|
||||
if (value === "flat") return "유지";
|
||||
return "평가 부족";
|
||||
}
|
||||
|
||||
function personaReviewStatusLabel(status: PersonaReviewStatus): string {
|
||||
if (status === "review") return "검수 대기";
|
||||
if (status === "draft") return "수정 대기";
|
||||
|
|
@ -38,6 +61,79 @@ function personaReviewTone(status: PersonaReviewStatus): "accent" | "neutral" |
|
|||
return "neutral";
|
||||
}
|
||||
|
||||
const EMPTY_PERSONA_DRAFT: PersonaDraftPayload = {
|
||||
code: "P4",
|
||||
display_name: "새 페르소나",
|
||||
difficulty: "moderate",
|
||||
theory_target: ["humanistic"],
|
||||
demographics: {
|
||||
age_band: "F-20s",
|
||||
},
|
||||
presenting: {
|
||||
complaint: "",
|
||||
},
|
||||
history: {},
|
||||
big5: {
|
||||
O: 0.5,
|
||||
C: 0.5,
|
||||
E: 0.5,
|
||||
A: 0.5,
|
||||
N: 0.5,
|
||||
},
|
||||
resistance: {
|
||||
base_resistance: 0.5,
|
||||
unlock_rate: 0.1,
|
||||
decay_floor: 0.05,
|
||||
silence_prob: 0.15,
|
||||
deflection_prob: 0.25,
|
||||
},
|
||||
speech_style: {
|
||||
register: "polite",
|
||||
avg_sentence_len: "medium",
|
||||
fillers: [],
|
||||
honorific: true,
|
||||
verbal_tics: [],
|
||||
},
|
||||
affect_baseline: {
|
||||
negative_affect: 0.45,
|
||||
hopelessness: 0.2,
|
||||
anhedonia: 0.2,
|
||||
sleep: 0.2,
|
||||
anxiety: 0.35,
|
||||
suicide_ideation_stage: 1,
|
||||
},
|
||||
ccd: {},
|
||||
dsm5_dimensional: {},
|
||||
source_provenance: "clinical draft",
|
||||
is_synthetic: true,
|
||||
submit_for_review: false,
|
||||
};
|
||||
|
||||
function stringifyDraft(payload: PersonaDraftPayload): string {
|
||||
return JSON.stringify(payload, null, 2);
|
||||
}
|
||||
|
||||
function draftDetailToPayload(detail: PersonaDraftDetail): PersonaDraftPayload {
|
||||
return {
|
||||
code: detail.code,
|
||||
display_name: detail.display_name,
|
||||
difficulty: detail.difficulty === "easy" || detail.difficulty === "hard" ? detail.difficulty : "moderate",
|
||||
theory_target: detail.theory_target,
|
||||
demographics: detail.demographics,
|
||||
presenting: detail.presenting,
|
||||
history: detail.history,
|
||||
big5: detail.big5,
|
||||
resistance: detail.resistance,
|
||||
speech_style: detail.speech_style,
|
||||
affect_baseline: detail.affect_baseline,
|
||||
ccd: detail.ccd,
|
||||
dsm5_dimensional: detail.dsm5_dimensional,
|
||||
source_provenance: detail.source_provenance,
|
||||
is_synthetic: detail.is_synthetic,
|
||||
submit_for_review: detail.status === "review",
|
||||
};
|
||||
}
|
||||
|
||||
function EmptyState({ title, desc }: { title: string; desc: string }) {
|
||||
return (
|
||||
<div className="pf-empty">
|
||||
|
|
@ -55,6 +151,11 @@ export default function Professor() {
|
|||
const [personaReviewLoading, setPersonaReviewLoading] = useState(true);
|
||||
const [personaReviewError, setPersonaReviewError] = useState<string | null>(null);
|
||||
const [personaReviewBusy, setPersonaReviewBusy] = useState<string | null>(null);
|
||||
const [draftJson, setDraftJson] = useState(() => stringifyDraft(EMPTY_PERSONA_DRAFT));
|
||||
const [draftEditingId, setDraftEditingId] = useState<string | null>(null);
|
||||
const [draftBusy, setDraftBusy] = useState<"load" | "save" | "submit" | null>(null);
|
||||
const [draftError, setDraftError] = useState<string | null>(null);
|
||||
const [draftMessage, setDraftMessage] = useState<string | null>(null);
|
||||
const [updatedAt, setUpdatedAt] = useState<Date | null>(null);
|
||||
|
||||
const loadDashboard = useCallback(async () => {
|
||||
|
|
@ -119,6 +220,73 @@ export default function Professor() {
|
|||
[],
|
||||
);
|
||||
|
||||
const resetPersonaDraft = useCallback(() => {
|
||||
setDraftJson(stringifyDraft(EMPTY_PERSONA_DRAFT));
|
||||
setDraftEditingId(null);
|
||||
setDraftError(null);
|
||||
setDraftMessage(null);
|
||||
}, []);
|
||||
|
||||
const parsePersonaDraft = useCallback(
|
||||
(submitForReview: boolean): PersonaDraftPayload => {
|
||||
const parsed = JSON.parse(draftJson) as PersonaDraftPayload;
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new Error("JSON 객체가 필요합니다.");
|
||||
}
|
||||
return {
|
||||
...parsed,
|
||||
submit_for_review: submitForReview,
|
||||
};
|
||||
},
|
||||
[draftJson],
|
||||
);
|
||||
|
||||
const savePersonaDraft = useCallback(
|
||||
async (submitForReview: boolean) => {
|
||||
setDraftBusy(submitForReview ? "submit" : "save");
|
||||
setDraftError(null);
|
||||
setDraftMessage(null);
|
||||
try {
|
||||
const payload = parsePersonaDraft(submitForReview);
|
||||
const updated = draftEditingId
|
||||
? await personaReviewApi.updateDraft(draftEditingId, payload)
|
||||
: await personaReviewApi.createDraft(payload);
|
||||
setDraftEditingId(updated.persona_id);
|
||||
setDraftMessage(
|
||||
updated.status === "review"
|
||||
? `${updated.code} v${updated.version} 검수 요청을 올렸습니다.`
|
||||
: `${updated.code} v${updated.version} 초안을 저장했습니다.`,
|
||||
);
|
||||
await loadPersonaReviews();
|
||||
} catch (err) {
|
||||
if (err instanceof SyntaxError) {
|
||||
setDraftError("JSON 형식이 올바르지 않습니다.");
|
||||
} else {
|
||||
setDraftError(err instanceof Error ? err.message : "페르소나 초안을 저장하지 못했습니다.");
|
||||
}
|
||||
} finally {
|
||||
setDraftBusy(null);
|
||||
}
|
||||
},
|
||||
[draftEditingId, loadPersonaReviews, parsePersonaDraft],
|
||||
);
|
||||
|
||||
const loadPersonaDraft = useCallback(async (personaId: string) => {
|
||||
setDraftBusy("load");
|
||||
setDraftError(null);
|
||||
setDraftMessage(null);
|
||||
try {
|
||||
const detail = await personaReviewApi.getDraft(personaId);
|
||||
setDraftEditingId(detail.persona_id);
|
||||
setDraftJson(stringifyDraft(draftDetailToPayload(detail)));
|
||||
setDraftMessage(`${detail.code} v${detail.version} 초안을 불러왔습니다.`);
|
||||
} catch (err) {
|
||||
setDraftError(err instanceof Error ? err.message : "페르소나 초안을 불러오지 못했습니다.");
|
||||
} finally {
|
||||
setDraftBusy(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const kpis = useMemo(
|
||||
() => [
|
||||
{
|
||||
|
|
@ -139,6 +307,12 @@ export default function Professor() {
|
|||
hint: "저장 완료",
|
||||
icon: "check" as const,
|
||||
},
|
||||
{
|
||||
label: "위기 알림",
|
||||
value: dashboard?.safety_alerts.length ?? 0,
|
||||
hint: "109 확인",
|
||||
icon: "alert" as const,
|
||||
},
|
||||
{
|
||||
label: "리뷰 대기",
|
||||
value: dashboard?.pending_reviews.length ?? 0,
|
||||
|
|
@ -152,6 +326,8 @@ export default function Professor() {
|
|||
const hasPending = pendingCount > 0;
|
||||
const totalSessions = (dashboard?.active_sessions ?? 0) + (dashboard?.ended_sessions ?? 0);
|
||||
const personaReviewCount = personaReviews.length;
|
||||
const safetyAlerts = dashboard?.safety_alerts ?? [];
|
||||
const learnerGrowth = dashboard?.learner_growth ?? [];
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
|
|
@ -225,8 +401,99 @@ export default function Professor() {
|
|||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="pf-section pf-section--growth">
|
||||
<div className="pf-section__head">
|
||||
<div>
|
||||
<Kicker>학습자 성장 추적</Kicker>
|
||||
<h2>이력·항목별 추이</h2>
|
||||
</div>
|
||||
<Badge tone={learnerGrowth.length > 0 ? "accent" : "neutral"}>
|
||||
{learnerGrowth.length}명
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<Card className="pf-panel pf-growth-panel">
|
||||
{learnerGrowth.length > 0 ? (
|
||||
<div className="pf-growth-list">
|
||||
{learnerGrowth.map((learner) => (
|
||||
<GrowthCard learner={learner} key={learner.learner_id} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
title={loadState === "loading" ? "성장 지표 계산 중" : "표시할 성장 이력 없음"}
|
||||
desc="학습자 회기와 턴별 평가가 쌓이면 적절성·라포·기법 사용 추이를 표시합니다."
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<section className="pf-workspace">
|
||||
<div className="pf-queue-stack">
|
||||
<section className="pf-section">
|
||||
<div className="pf-section__head">
|
||||
<div>
|
||||
<Kicker>페르소나 저작</Kicker>
|
||||
<h2>초안 작성</h2>
|
||||
</div>
|
||||
<Badge tone={draftEditingId ? "warn" : "neutral"}>
|
||||
{draftEditingId ? "편집 중" : "새 초안"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<Card className="pf-panel pf-draft-panel">
|
||||
<div className="pf-draft-toolbar">
|
||||
<span>{draftEditingId ? "기존 초안 수정" : "새 페르소나 버전"}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
leading={<Icon name="x" size={14} />}
|
||||
onClick={resetPersonaDraft}
|
||||
disabled={draftBusy !== null}
|
||||
>
|
||||
초기화
|
||||
</Button>
|
||||
</div>
|
||||
<textarea
|
||||
className="pf-draft-json"
|
||||
value={draftJson}
|
||||
onChange={(event) => {
|
||||
setDraftJson(event.target.value);
|
||||
setDraftError(null);
|
||||
setDraftMessage(null);
|
||||
}}
|
||||
spellCheck={false}
|
||||
aria-label="페르소나 JSON 초안"
|
||||
/>
|
||||
{draftError ? (
|
||||
<p className="pf-draft-status is-error" role="alert">
|
||||
{draftError}
|
||||
</p>
|
||||
) : draftMessage ? (
|
||||
<p className="pf-draft-status">{draftMessage}</p>
|
||||
) : null}
|
||||
<div className="pf-draft-actions">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
leading={<Icon name="check" size={14} />}
|
||||
onClick={() => void savePersonaDraft(false)}
|
||||
disabled={draftBusy !== null}
|
||||
>
|
||||
{draftBusy === "save" ? "저장 중" : "초안 저장"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
leading={<Icon name="review" size={14} />}
|
||||
onClick={() => void savePersonaDraft(true)}
|
||||
disabled={draftBusy !== null}
|
||||
>
|
||||
{draftBusy === "submit" ? "요청 중" : "검수 요청"}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<section className="pf-section">
|
||||
<div className="pf-section__head">
|
||||
<div>
|
||||
|
|
@ -284,6 +551,15 @@ export default function Professor() {
|
|||
<span>{formatDateTime(persona.created_at)}</span>
|
||||
</div>
|
||||
<div className="pf-persona__actions">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
leading={<Icon name="review" size={14} />}
|
||||
onClick={() => void loadPersonaDraft(persona.persona_id)}
|
||||
disabled={busy || draftBusy !== null}
|
||||
>
|
||||
{draftBusy === "load" && draftEditingId === persona.persona_id ? "불러오는 중" : "편집"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
|
|
@ -315,6 +591,33 @@ export default function Professor() {
|
|||
</Card>
|
||||
</section>
|
||||
|
||||
<section className="pf-section">
|
||||
<div className="pf-section__head">
|
||||
<div>
|
||||
<Kicker>위기 알림</Kicker>
|
||||
<h2>109 안전 확인 큐</h2>
|
||||
</div>
|
||||
<Badge tone={safetyAlerts.length > 0 ? "warn" : "neutral"}>
|
||||
{safetyAlerts.length}건
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<Card className="pf-panel">
|
||||
{safetyAlerts.length > 0 ? (
|
||||
<div className="pf-alerts">
|
||||
{safetyAlerts.map((alert) => (
|
||||
<SafetyAlertRow alert={alert} key={alert.id} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="현재 위기 알림 없음"
|
||||
desc="실제 위기 신호가 감지되면 이 목록에 109 확인 큐로 표시됩니다."
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<section className="pf-section">
|
||||
<div className="pf-section__head">
|
||||
<div>
|
||||
|
|
@ -422,6 +725,110 @@ export default function Professor() {
|
|||
);
|
||||
}
|
||||
|
||||
function GrowthCard({ learner }: { learner: TeacherLearnerGrowth }) {
|
||||
const recentPoints = learner.points.slice(-3).reverse();
|
||||
return (
|
||||
<article className={`pf-growth-card trend-${learner.trend}`}>
|
||||
<div className="pf-growth-card__top">
|
||||
<div className="pf-growth-card__id">
|
||||
<b>{learner.learner_label}</b>
|
||||
<span>
|
||||
{learner.ended_sessions}/{learner.sessions}회기 완료 · {formatDateTime(learner.latest_at)}
|
||||
</span>
|
||||
</div>
|
||||
<Badge tone={learner.trend === "down" ? "warn" : learner.trend === "up" ? "accent" : "neutral"}>
|
||||
{trendLabel(learner.trend)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="pf-growth-card__metrics" aria-label="학습자 성장 요약">
|
||||
<span>
|
||||
<small>최근 적절성</small>
|
||||
<b>{formatScore(learner.latest_score)}</b>
|
||||
</span>
|
||||
<span>
|
||||
<small>변화</small>
|
||||
<b>{formatDelta(learner.score_delta)}</b>
|
||||
</span>
|
||||
<span>
|
||||
<small>평균 라포</small>
|
||||
<b>{formatScore(learner.avg_rapport == null ? null : (learner.avg_rapport + 1) / 2)}</b>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="pf-growth-bars" aria-label="회기별 적절성 추이">
|
||||
{learner.points.map((point) => (
|
||||
<GrowthBar point={point} key={point.session_id} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="pf-growth-card__tags">
|
||||
{learner.top_techniques.length > 0 ? (
|
||||
learner.top_techniques.map((tag) => <span key={tag}>{tag}</span>)
|
||||
) : (
|
||||
<span>기법 태그 부족</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pf-growth-card__points">
|
||||
{recentPoints.length > 0 ? (
|
||||
recentPoints.map((point) => (
|
||||
<div className="pf-growth-point" key={point.session_id}>
|
||||
<b>
|
||||
{point.persona_code} · {point.session_no}회기
|
||||
</b>
|
||||
<span>
|
||||
{formatScore(point.score)} · 기법 {point.technique_count} · 점검 {point.watch_count}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="pf-growth-point">
|
||||
<b>회기 평가 부족</b>
|
||||
<span>종료 회기와 턴별 평가가 필요합니다.</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function GrowthBar({ point }: { point: TeacherGrowthPoint }) {
|
||||
const hasScore = typeof point.score === "number" && !Number.isNaN(point.score);
|
||||
const height = hasScore ? Math.max(10, Math.round((point.score ?? 0) * 100)) : 10;
|
||||
return (
|
||||
<span
|
||||
className={`pf-growth-bar ${hasScore ? "" : "is-empty"}`}
|
||||
title={`${point.persona_code} ${point.session_no}회기 · ${formatScore(point.score)}`}
|
||||
>
|
||||
<i style={{ height: `${height}%` }} />
|
||||
<small>{point.session_no}</small>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SafetyAlertRow({ alert }: { alert: TeacherSafetyAlert }) {
|
||||
return (
|
||||
<article className="pf-alert">
|
||||
<span className="pf-alert__ic" aria-hidden="true">
|
||||
<Icon name="alert" size={16} />
|
||||
</span>
|
||||
<div className="pf-alert__main">
|
||||
<b>{alert.learner_label}</b>
|
||||
<span>
|
||||
{alert.persona_code || "세션"} · 위험도 {alert.ko_risk_level} ·{" "}
|
||||
{formatDateTime(alert.created_at)}
|
||||
</span>
|
||||
<code>{alert.session_id}</code>
|
||||
</div>
|
||||
<div className="pf-alert__resource">
|
||||
<span>{alert.resource_title}</span>
|
||||
<b>{alert.resource_number}</b>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
const PF_CSS = `
|
||||
.pf-root{
|
||||
max-width:var(--maxw);
|
||||
|
|
@ -526,7 +933,7 @@ const PF_CSS = `
|
|||
}
|
||||
.pf-kpis{
|
||||
display:grid;
|
||||
grid-template-columns:repeat(2,minmax(0,1fr));
|
||||
grid-template-columns:repeat(3,minmax(0,1fr));
|
||||
border:1px solid var(--hair);
|
||||
border-radius:var(--radius);
|
||||
overflow:hidden;
|
||||
|
|
@ -541,8 +948,8 @@ const PF_CSS = `
|
|||
grid-template-columns:minmax(0,1fr) auto;
|
||||
gap:4px 10px;
|
||||
}
|
||||
.pf-kpi:nth-child(even){border-left:1px solid var(--hair);}
|
||||
.pf-kpi:nth-child(n+3){border-top:1px solid var(--hair);}
|
||||
.pf-kpi:nth-child(n+2){border-left:1px solid var(--hair);}
|
||||
.pf-kpi:nth-child(n+4){border-top:1px solid var(--hair);}
|
||||
.pf-kpi__ic{
|
||||
grid-column:2;
|
||||
grid-row:1 / span 3;
|
||||
|
|
@ -609,6 +1016,213 @@ const PF_CSS = `
|
|||
padding:0;
|
||||
overflow:hidden;
|
||||
}
|
||||
.pf-draft-panel{
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
gap:10px;
|
||||
padding:12px;
|
||||
}
|
||||
.pf-draft-toolbar{
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:space-between;
|
||||
gap:10px;
|
||||
min-width:0;
|
||||
}
|
||||
.pf-draft-toolbar span{
|
||||
color:var(--text-muted);
|
||||
font-size:var(--fs-xs);
|
||||
font-weight:700;
|
||||
}
|
||||
.pf-draft-json{
|
||||
width:100%;
|
||||
min-height:240px;
|
||||
max-height:min(420px,48vh);
|
||||
resize:vertical;
|
||||
overflow:auto;
|
||||
padding:11px 12px;
|
||||
border:1px solid var(--line-strong);
|
||||
border-radius:var(--radius);
|
||||
background:var(--bg);
|
||||
color:var(--text-strong);
|
||||
font-family:var(--font-num);
|
||||
font-size:11px;
|
||||
line-height:1.55;
|
||||
outline:none;
|
||||
}
|
||||
.pf-draft-json:focus{
|
||||
border-color:var(--accent);
|
||||
box-shadow:0 0 0 3px var(--accent-tint);
|
||||
}
|
||||
.pf-draft-status{
|
||||
margin:0;
|
||||
color:var(--accent-deep);
|
||||
font-size:var(--fs-xs);
|
||||
font-weight:700;
|
||||
}
|
||||
.pf-draft-status.is-error{
|
||||
color:var(--crit-text);
|
||||
}
|
||||
.pf-draft-actions{
|
||||
display:flex;
|
||||
justify-content:flex-end;
|
||||
gap:8px;
|
||||
min-width:0;
|
||||
}
|
||||
.pf-draft-actions .vg-btn{
|
||||
min-width:84px;
|
||||
}
|
||||
.pf-section--growth{
|
||||
min-width:0;
|
||||
}
|
||||
.pf-growth-panel{
|
||||
background:linear-gradient(180deg,var(--bg-surface),color-mix(in srgb,var(--accent-tint) 18%,var(--bg-surface)));
|
||||
}
|
||||
.pf-growth-list{
|
||||
display:grid;
|
||||
grid-template-columns:repeat(3,minmax(0,1fr));
|
||||
gap:12px;
|
||||
padding:12px;
|
||||
}
|
||||
.pf-growth-card{
|
||||
min-width:0;
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
gap:12px;
|
||||
padding:13px;
|
||||
border:1px solid var(--paper-2);
|
||||
border-radius:var(--radius);
|
||||
background:var(--bg-surface);
|
||||
box-shadow:var(--shadow-sm);
|
||||
}
|
||||
.pf-growth-card__top{
|
||||
display:flex;
|
||||
align-items:flex-start;
|
||||
justify-content:space-between;
|
||||
gap:10px;
|
||||
min-width:0;
|
||||
}
|
||||
.pf-growth-card__id{
|
||||
min-width:0;
|
||||
display:grid;
|
||||
gap:3px;
|
||||
}
|
||||
.pf-growth-card__id b{
|
||||
color:var(--text-strong);
|
||||
font-size:var(--fs-sm);
|
||||
line-height:1.35;
|
||||
overflow:hidden;
|
||||
text-overflow:ellipsis;
|
||||
white-space:nowrap;
|
||||
}
|
||||
.pf-growth-card__id span,
|
||||
.pf-growth-card__metrics small,
|
||||
.pf-growth-point span{
|
||||
color:var(--text-muted);
|
||||
font-size:12px;
|
||||
line-height:1.4;
|
||||
}
|
||||
.pf-growth-card__metrics{
|
||||
display:grid;
|
||||
grid-template-columns:repeat(3,minmax(0,1fr));
|
||||
gap:8px;
|
||||
}
|
||||
.pf-growth-card__metrics span{
|
||||
min-width:0;
|
||||
display:grid;
|
||||
gap:3px;
|
||||
padding:9px 10px;
|
||||
border:1px solid var(--paper-2);
|
||||
border-radius:var(--radius-sm);
|
||||
background:var(--bg-surface-2);
|
||||
}
|
||||
.pf-growth-card__metrics b{
|
||||
color:var(--text-strong);
|
||||
font-family:var(--font-num);
|
||||
font-size:15px;
|
||||
line-height:1.15;
|
||||
white-space:nowrap;
|
||||
}
|
||||
.pf-growth-bars{
|
||||
height:82px;
|
||||
display:flex;
|
||||
align-items:flex-end;
|
||||
gap:6px;
|
||||
padding:8px 8px 6px;
|
||||
border:1px solid var(--paper-2);
|
||||
border-radius:var(--radius-sm);
|
||||
background:color-mix(in srgb,var(--bg-surface-2) 82%,transparent);
|
||||
}
|
||||
.pf-growth-bar{
|
||||
flex:1 1 0;
|
||||
min-width:14px;
|
||||
height:100%;
|
||||
display:grid;
|
||||
grid-template-rows:minmax(0,1fr) 14px;
|
||||
gap:4px;
|
||||
align-items:end;
|
||||
}
|
||||
.pf-growth-bar i{
|
||||
display:block;
|
||||
width:100%;
|
||||
min-height:6px;
|
||||
border-radius:6px 6px 3px 3px;
|
||||
background:linear-gradient(180deg,var(--accent),var(--accent-deep));
|
||||
}
|
||||
.pf-growth-bar.is-empty i{
|
||||
background:repeating-linear-gradient(135deg,var(--paper-2),var(--paper-2) 3px,var(--hair) 3px,var(--hair) 6px);
|
||||
}
|
||||
.pf-growth-bar small{
|
||||
color:var(--text-muted);
|
||||
font-family:var(--font-num);
|
||||
font-size:10px;
|
||||
text-align:center;
|
||||
line-height:1;
|
||||
}
|
||||
.pf-growth-card__tags{
|
||||
min-height:26px;
|
||||
display:flex;
|
||||
flex-wrap:wrap;
|
||||
gap:6px;
|
||||
align-content:flex-start;
|
||||
}
|
||||
.pf-growth-card__tags span{
|
||||
max-width:100%;
|
||||
padding:4px 7px;
|
||||
border:1px solid var(--paper-2);
|
||||
border-radius:999px;
|
||||
color:var(--text-body);
|
||||
background:var(--bg-surface-2);
|
||||
font-size:11px;
|
||||
line-height:1.2;
|
||||
overflow:hidden;
|
||||
text-overflow:ellipsis;
|
||||
white-space:nowrap;
|
||||
}
|
||||
.pf-growth-card__points{
|
||||
display:grid;
|
||||
gap:7px;
|
||||
}
|
||||
.pf-growth-point{
|
||||
min-width:0;
|
||||
display:grid;
|
||||
grid-template-columns:minmax(86px,.5fr) minmax(0,1fr);
|
||||
gap:8px;
|
||||
align-items:center;
|
||||
}
|
||||
.pf-growth-point b{
|
||||
color:var(--text-strong);
|
||||
font-size:12px;
|
||||
line-height:1.35;
|
||||
overflow:hidden;
|
||||
text-overflow:ellipsis;
|
||||
white-space:nowrap;
|
||||
}
|
||||
.pf-growth-point span{
|
||||
overflow:hidden;
|
||||
text-overflow:ellipsis;
|
||||
white-space:nowrap;
|
||||
}
|
||||
.pf-empty{
|
||||
min-height:118px;
|
||||
display:grid;
|
||||
|
|
@ -707,6 +1321,63 @@ const PF_CSS = `
|
|||
min-width:72px;
|
||||
padding-inline:10px;
|
||||
}
|
||||
.pf-alerts{
|
||||
max-height:min(300px,38vh);
|
||||
overflow:auto;
|
||||
scrollbar-gutter:stable;
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
}
|
||||
.pf-alert{
|
||||
display:grid;
|
||||
grid-template-columns:auto minmax(0,1fr) auto;
|
||||
gap:10px;
|
||||
align-items:center;
|
||||
padding:12px;
|
||||
border-top:1px solid var(--paper-2);
|
||||
background:color-mix(in srgb,var(--warn-tint) 34%,transparent);
|
||||
}
|
||||
.pf-alert:first-child{border-top:0;}
|
||||
.pf-alert__ic{
|
||||
width:30px;
|
||||
height:30px;
|
||||
display:grid;
|
||||
place-items:center;
|
||||
border-radius:var(--radius);
|
||||
color:var(--warn-text);
|
||||
background:var(--warn-tint);
|
||||
}
|
||||
.pf-alert__main{
|
||||
min-width:0;
|
||||
display:grid;
|
||||
gap:3px;
|
||||
}
|
||||
.pf-alert__main b{
|
||||
color:var(--text-strong);
|
||||
font-size:var(--fs-sm);
|
||||
}
|
||||
.pf-alert__main span,
|
||||
.pf-alert__main code,
|
||||
.pf-alert__resource span{
|
||||
color:var(--text-muted);
|
||||
font-size:12px;
|
||||
}
|
||||
.pf-alert__main code{
|
||||
overflow:hidden;
|
||||
text-overflow:ellipsis;
|
||||
white-space:nowrap;
|
||||
}
|
||||
.pf-alert__resource{
|
||||
display:grid;
|
||||
gap:2px;
|
||||
justify-items:end;
|
||||
min-width:86px;
|
||||
}
|
||||
.pf-alert__resource b{
|
||||
color:var(--warn-text);
|
||||
font-family:var(--font-num);
|
||||
font-size:18px;
|
||||
}
|
||||
.pf-session{
|
||||
display:grid;
|
||||
grid-template-columns:8px minmax(0,1fr);
|
||||
|
|
@ -831,9 +1502,11 @@ const PF_CSS = `
|
|||
.pf-kpis{
|
||||
grid-template-columns:repeat(4,minmax(0,1fr));
|
||||
}
|
||||
.pf-kpi:nth-child(even),
|
||||
.pf-kpi + .pf-kpi{border-left:1px solid var(--hair);}
|
||||
.pf-kpi:nth-child(n+3){border-top:0;}
|
||||
.pf-kpi:nth-child(n+2){border-left:1px solid var(--hair);}
|
||||
.pf-kpi:nth-child(n+4){border-top:0;}
|
||||
.pf-growth-list{
|
||||
grid-template-columns:repeat(2,minmax(0,1fr));
|
||||
}
|
||||
.pf-list,
|
||||
.pf-personas{
|
||||
max-height:360px;
|
||||
|
|
@ -851,8 +1524,12 @@ const PF_CSS = `
|
|||
width:100%;
|
||||
}
|
||||
.pf-kpis{grid-template-columns:repeat(2,minmax(0,1fr));}
|
||||
.pf-kpi:nth-child(odd){border-left:0;}
|
||||
.pf-kpi:nth-child(n+2){border-left:0;}
|
||||
.pf-kpi:nth-child(even){border-left:1px solid var(--hair);}
|
||||
.pf-kpi:nth-child(n+3){border-top:1px solid var(--hair);}
|
||||
.pf-growth-list{
|
||||
grid-template-columns:1fr;
|
||||
}
|
||||
.pf-recent-list{
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
|
|
@ -911,6 +1588,27 @@ const PF_CSS = `
|
|||
.pf-persona__actions .vg-btn{
|
||||
width:100%;
|
||||
}
|
||||
.pf-growth-list{
|
||||
padding:10px;
|
||||
}
|
||||
.pf-growth-card__metrics{
|
||||
grid-template-columns:1fr;
|
||||
}
|
||||
.pf-growth-point{
|
||||
grid-template-columns:1fr;
|
||||
gap:2px;
|
||||
}
|
||||
.pf-growth-point b,
|
||||
.pf-growth-point span{
|
||||
white-space:normal;
|
||||
}
|
||||
.pf-alert{
|
||||
grid-template-columns:auto minmax(0,1fr);
|
||||
}
|
||||
.pf-alert__resource{
|
||||
grid-column:2;
|
||||
justify-items:start;
|
||||
}
|
||||
.pf-recent-list{
|
||||
padding:8px;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue