@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Profile に関する派生計算ヘルパー。
|
||||
*
|
||||
* ADR-0014「shadcn 標準フォームによる Pane 4 編集 UI」で Profile から `age` を
|
||||
* 撤去し、`birthday` から派生計算する方針に変更したため、その単一ソースとしてここに集約する。
|
||||
*
|
||||
* 設計原則:
|
||||
* - 純粋関数のみ(副作用なし、`new Date()` の都度呼び出しは許容)
|
||||
* - 不正/空入力は空文字 ""(または undefined)で返す(throw しない)
|
||||
* - 日付の保存形式は ANSI / ISO 8601 短形式(YYYY-MM-DD)に統一
|
||||
*/
|
||||
|
||||
/**
|
||||
* `YYYY-MM-DD` 形式の文字列を `Date` に変換する。
|
||||
* 空文字 / 不正フォーマットは `undefined` を返す。
|
||||
*
|
||||
* 時刻は `T00:00:00` をローカルタイムで補い、タイムゾーン跨ぎを避ける。
|
||||
*/
|
||||
export function parseISODate(s: string): Date | undefined {
|
||||
if (!s) return undefined;
|
||||
const d = new Date(s + "T00:00:00");
|
||||
return Number.isNaN(d.getTime()) ? undefined : d;
|
||||
}
|
||||
|
||||
/**
|
||||
* `Date` を `YYYY-MM-DD` 形式の文字列に整形する。
|
||||
* `undefined` または不正な Date は空文字を返す。
|
||||
*/
|
||||
export function formatISODate(d: Date | undefined): string {
|
||||
if (!d || Number.isNaN(d.getTime())) return "";
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生年月日から年齢を派生計算し、「N 歳」形式で返す。
|
||||
* 空 / 不正な birthday は空文字を返す(UI 側で「未表示」になる)。
|
||||
*
|
||||
* 「今日」は `new Date()`。Pane 4 は `"use client"` なのでクライアント側で都度評価される。
|
||||
* SSR / hydration mismatch は発生しない。
|
||||
*/
|
||||
export function calculateAge(birthday: string): string {
|
||||
const birth = parseISODate(birthday);
|
||||
if (!birth) return "";
|
||||
const today = new Date();
|
||||
let age = today.getFullYear() - birth.getFullYear();
|
||||
const m = today.getMonth() - birth.getMonth();
|
||||
if (m < 0 || (m === 0 && today.getDate() < birth.getDate())) age--;
|
||||
if (age < 0) return "";
|
||||
return `${age} 歳`;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Scorecard / Candidate の派生計算(評価系)SSoT。
|
||||
*
|
||||
* Pane 2 候補者行の平均スコアバッジ (★ 4.5 / —) と、
|
||||
* Pane 3 評価カードの「平均スコア」行は、同じ意味の値を表示する。
|
||||
* 同じ計算を 2 箇所で持つと「ある日 Pane 2 だけ違う値が出る」事故が発生するため、
|
||||
* 計算ロジックは本ファイルの 3 関数に集約する MUST。
|
||||
*
|
||||
* 関連:
|
||||
* - openspec/decision/0013-list-pane-lightweight-header-and-row-evaluation.md
|
||||
* - openspec/decision/0010-pane-responsibility-and-candidate-detail-mode.md
|
||||
* §11 候補者単位 vs ステージ単位の責務分離(評価はステージ単位だが、
|
||||
* 候補者代表値として「最新 done scorecard の平均」を使う)
|
||||
*/
|
||||
|
||||
import {
|
||||
type Candidate,
|
||||
type AxisScores,
|
||||
type Scorecard,
|
||||
type StageStatus,
|
||||
AXIS_ORDER,
|
||||
STAGE_ORDER,
|
||||
} from "@/lib/schema";
|
||||
|
||||
/**
|
||||
* 「最後に done 状態となった scorecard」を返す。done が存在しない場合は undefined。
|
||||
*
|
||||
* 配列の末尾に近いものを優先することで、選考フロー (書類 → 一次 → 二次 → 最終) で
|
||||
* 「最も進んだステージの評価」を代表値として取り出せる。シード `INITIAL_SCORECARDS`
|
||||
* は STAGE_ORDER と同じ順で並ぶ前提(design.md / spec.md)。
|
||||
*/
|
||||
export function getLatestDoneScorecard(
|
||||
scorecards: Scorecard[],
|
||||
): Scorecard | undefined {
|
||||
return [...scorecards]
|
||||
.reverse()
|
||||
.find((s) => deriveStageStatus(s.date, s.decision) === "done");
|
||||
}
|
||||
|
||||
/**
|
||||
* 1 つの scorecard の axisScores(4 観点)の平均を返す。null は除外。
|
||||
*
|
||||
* 全 axis が null(未入力)の場合は null を返す(呼び出し側で「—」表示)。
|
||||
* 丸めは行わず、表示側で `toFixed(1)` 等を適用する。
|
||||
*/
|
||||
export function calculateAverageScore(axisScores: AxisScores): number | null {
|
||||
const values = AXIS_ORDER.map((k) => axisScores[k]).filter(
|
||||
(v): v is number => v !== null,
|
||||
);
|
||||
if (values.length === 0) return null;
|
||||
return values.reduce((a, b) => a + b, 0) / values.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* 候補者の「代表平均スコア」を返す。
|
||||
* = 最新 done scorecard の axisScores の平均。
|
||||
*
|
||||
* Pane 2 候補者行のサブテキスト (★ 4.5 / —) と、Pane 3 評価カードの「平均スコア」行で
|
||||
* 同じ意味の値を出すために使う。done scorecard が無い・全 axis が null・候補者が
|
||||
* scorecards を持たないいずれの場合も null(未評価扱い)。
|
||||
*/
|
||||
export function getCandidateAverageScore(candidate: Candidate): number | null {
|
||||
const latest = getLatestDoneScorecard(candidate.scorecards);
|
||||
if (!latest) return null;
|
||||
return calculateAverageScore(latest.axisScores);
|
||||
}
|
||||
|
||||
/**
|
||||
* 完了済み(`status === "done"`)かつコメントあり(`comment` が truthy)の
|
||||
* scorecard を STAGE_ORDER 順で返す。Pane 3「担当者コメント」Card の行リストに使う。
|
||||
*
|
||||
* 空配列が返った場合は「コメントはまだ記入されていません」の placeholder を表示する。
|
||||
*/
|
||||
export function getCommentedScorecards(scorecards: Scorecard[]): Scorecard[] {
|
||||
const stageIndex = Object.fromEntries(
|
||||
STAGE_ORDER.map((s, i) => [s, i]),
|
||||
) as Record<string, number>;
|
||||
return scorecards
|
||||
.filter(
|
||||
(s) => deriveStageStatus(s.date, s.decision) === "done" && s.comment,
|
||||
)
|
||||
.sort((a, b) => (stageIndex[a.stage] ?? 0) - (stageIndex[b.stage] ?? 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* scorecards 配列から候補者の代表平均スコアを計算する。
|
||||
* `getCandidateAverageScore` の Candidate 型を要求しない版。
|
||||
* Pane 3 ヘッダー帯の `<ScoreLabel>` で使う。
|
||||
*/
|
||||
export function getScorecardsAverageScore(
|
||||
scorecards: Scorecard[],
|
||||
): number | null {
|
||||
const latest = getLatestDoneScorecard(scorecards);
|
||||
if (!latest) return null;
|
||||
return calculateAverageScore(latest.axisScores);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scorecard の date / decision の有無から状態を派生計算する。
|
||||
*
|
||||
* ADR-0015 §17.1 追加決定 I で `Scorecard.status` フィールドを廃止したため、
|
||||
* 表示時に都度この関数で導出する MUST。
|
||||
*
|
||||
* - decision あり (truthy) → done (実施済 + 結果あり)
|
||||
* - decision なし、date あり (truthy) → planned (予定済)
|
||||
* - 両方なし → pending (未着手)
|
||||
*
|
||||
* Pane 3 選考フローカードの StageIcon、コメント抽出のフィルタ、ヘッダー帯の
|
||||
* ★ スコア表示など、status を必要とするすべての表示で使う。
|
||||
*/
|
||||
export function deriveStageStatus(
|
||||
date: string,
|
||||
decision?: string,
|
||||
): StageStatus {
|
||||
if (decision && decision.trim() !== "") return "done";
|
||||
if (date && date.trim() !== "") return "planned";
|
||||
return "pending";
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { type Profile, type StageKey, type Scorecard } from "@/lib/schema";
|
||||
import { STAGE_LABELS } from "@/lib/labels";
|
||||
|
||||
/**
|
||||
* 未作成ステージをクリックした時に Workspace が自動生成するための最小 Scorecard。
|
||||
* 全フィールド空(`deriveStageStatus` で pending 派生)。Pane 4 Mode 2 で inline edit すれば
|
||||
* 面接官アサインや日程設定ができる。
|
||||
*/
|
||||
export function createMinimalScorecard(stage: StageKey): Scorecard {
|
||||
return {
|
||||
stage,
|
||||
label: STAGE_LABELS[stage],
|
||||
date: "",
|
||||
format: "",
|
||||
interviewer: "",
|
||||
axisScores: {
|
||||
achievements: null,
|
||||
thinkingAbility: null,
|
||||
communication: null,
|
||||
cultureFit: null,
|
||||
},
|
||||
attachments: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* c1 / c3 / c4 / c5 / c6 など「c2 以外の候補者」用の最小 Profile 生成ヘルパー。
|
||||
* ADR-0014 で Profile が 12 フィールド最小構成になり、avatar 頭文字は
|
||||
* 呼び出し側で `name[0]` 派生に統一されたため、`initial` 引数は削除済み。
|
||||
* 氏名以外は空文字に揃え、Pane 3 / Pane 4 では空欄として可視化される。
|
||||
*
|
||||
* `addCandidate`(Workspace 本体)からも参照されるため export する。
|
||||
*/
|
||||
export function createMinimalProfile(name: string): Profile {
|
||||
return {
|
||||
// プロフィール (3)
|
||||
name,
|
||||
birthday: "",
|
||||
source: "",
|
||||
|
||||
// 連絡先 (3)
|
||||
email: "",
|
||||
phone: "",
|
||||
address: "",
|
||||
|
||||
// 選考状況 (4)
|
||||
recruiter: "",
|
||||
desiredSalaryMin: "",
|
||||
desiredSalaryMax: "",
|
||||
availableStartDate: "",
|
||||
|
||||
// 読み物 (2)
|
||||
careerText: "",
|
||||
motivationFull: "",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* 雛形の表示文言(labels)。
|
||||
*
|
||||
* 業種を変える受講生は、このファイルの値を業種に合わせて書き換える。
|
||||
* 例: 学校なら EVALUATION_AXIS を「学力 / 思考力 / 表現力 / 主体性」に、
|
||||
* STAGE_LABELS を「見学 / 体験授業 / 面接 / 契約」に変更する。
|
||||
*
|
||||
* 後フェーズの予定:
|
||||
* - Phase C: 評価観点と選考ステージを Tagged Union 化したデータ駆動に
|
||||
* - 現時点では key → 日本語ラベルの単純なマッピングとして持つ
|
||||
*/
|
||||
|
||||
import { type AxisKey, type StageKey } from "@/lib/schema";
|
||||
|
||||
// ===== 評価観点(4 軸固定、ADR-0005 §13 / design.md D57) =====
|
||||
// この雛形は 実績 / 思考力 / コミュニケーション / カルチャーフィット の 4 軸を採用する
|
||||
|
||||
export const EVALUATION_AXIS: Record<AxisKey, string> = {
|
||||
achievements: "実績",
|
||||
thinkingAbility: "思考力",
|
||||
communication: "コミュニケーション",
|
||||
cultureFit: "カルチャーフィット",
|
||||
} as const;
|
||||
|
||||
// Pane 2 のグループ見出しに出すステージ表示名(日本語)。
|
||||
// `Scorecard.label` とは独立に持つ(候補者ごとの個別ラベルではなく、
|
||||
// 列としてのステージ名なので Record で固定)。
|
||||
export const STAGE_LABELS: Record<StageKey, string> = {
|
||||
screening: "書類選考",
|
||||
first: "一次面接",
|
||||
second: "二次面接",
|
||||
final: "最終面接",
|
||||
};
|
||||
|
||||
// ===== Pane 3 ダッシュボードのセクション見出し(ADR-0014) =====
|
||||
|
||||
export const PANE3_SECTION = {
|
||||
applicationInfo: "応募情報",
|
||||
recruitingConditions: "採用条件",
|
||||
screeningFlow: "選考フロー",
|
||||
screeningFlowDescription: "進捗と面接担当者のコメント",
|
||||
} as const;
|
||||
|
||||
// ===== Pane 4 セクション id(ADR-0015 §19 でモード 1 廃止、m2 のみ) =====
|
||||
|
||||
export const PANE4_SECTION_IDS = {
|
||||
m2: {
|
||||
info: "pane4-m2-info",
|
||||
evaluation: "pane4-m2-evaluation",
|
||||
comment: "pane4-m2-comment",
|
||||
summary: "pane4-m2-summary",
|
||||
attachments: "pane4-m2-attachments",
|
||||
},
|
||||
} as const;
|
||||
+216
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* 採用管理ドメインの Zod スキーマと派生型。
|
||||
* 雛形の SSoT として、UI コンポーネントはここから型をインポートする。
|
||||
*
|
||||
* 仕様の出典:
|
||||
* - openspec/decision/0005-dashboard-drilldown-and-global-header.md
|
||||
* - openspec/changes/add-4pane-workspace-template/specs/workspace-template/spec.md
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
// ===== Pane 1: 部署 → ポジション 階層 =====
|
||||
|
||||
/** 部署配下の単一ポジション。Pane 1 の階層 Sidebar に表示する単位。 */
|
||||
export const positionSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
count: z.number(),
|
||||
});
|
||||
export type Position = z.infer<typeof positionSchema>;
|
||||
|
||||
/** 部署と配下のポジション一覧。Pane 1 の階層 Sidebar の最上位単位。 */
|
||||
export const departmentSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
positions: z.array(positionSchema),
|
||||
});
|
||||
export type Department = z.infer<typeof departmentSchema>;
|
||||
|
||||
// ===== 候補者プロフィール =====
|
||||
|
||||
/**
|
||||
* 候補者プロフィール。Pane 3 ヘッダー帯トグル内 + 採用条件カードで表示・編集される。
|
||||
*
|
||||
* 構成: 12 フィールド(応募情報 3 / 連絡先 3 / 採用条件 3 / 読み物 2 / 採用担当 1)。
|
||||
*
|
||||
* ADR-0014「shadcn 標準フォームによる Pane 4 編集 UI」で Lab v3 の最小構成を採用し、
|
||||
* 旧 ADR-0010 §10 H の Profile 型を supersede。削除されたフィールド
|
||||
* (`initial` / `currentCompany` / `currentRole` / `nextActionDeadline` /
|
||||
* `otherCompanies` / `desiredSalary` / `age` / `experienceYears` / `applyPosition` /
|
||||
* `careerSummary` / `motivationSummary`) は「最小プロフィール」方針により撤去:
|
||||
*
|
||||
* - `name[0]` 派生で avatar の頭文字を生成(`initial` 廃止)
|
||||
* - `desiredSalary` は min/max に分割(`min`〜`max` 万円の構造化編集)
|
||||
* - `age` は `birthday` から `calculateAge` で派生表示
|
||||
* - 現職情報・経験年数は職務経歴 textarea に集約
|
||||
* - サマリ系(`careerSummary` / `motivationSummary`)は full 文字列だけで運用
|
||||
*/
|
||||
export const profileSchema = z.object({
|
||||
name: z.string(),
|
||||
birthday: z.string(),
|
||||
source: z.string(),
|
||||
email: z.string(),
|
||||
phone: z.string(),
|
||||
address: z.string(),
|
||||
recruiter: z.string(),
|
||||
desiredSalaryMin: z.string(),
|
||||
desiredSalaryMax: z.string(),
|
||||
availableStartDate: z.string(),
|
||||
careerText: z.string(),
|
||||
motivationFull: z.string(),
|
||||
});
|
||||
export type Profile = z.infer<typeof profileSchema>;
|
||||
|
||||
// ===== 評価観点(4 軸固定、ADR-0005 §13 / design.md D57) =====
|
||||
// この雛形は 実績 / 思考力 / コミュニケーション / カルチャーフィット の 4 軸を採用する
|
||||
|
||||
/** 評価観点キー。Scorecard.axisScores のキーと一致する。 */
|
||||
export const axisKeySchema = z.enum([
|
||||
"achievements",
|
||||
"thinkingAbility",
|
||||
"communication",
|
||||
"cultureFit",
|
||||
]);
|
||||
export type AxisKey = z.infer<typeof axisKeySchema>;
|
||||
|
||||
/** 4 観点別スコア。未評価は null。平均スコアは null 除外で派生計算する。 */
|
||||
export const axisScoresSchema = z.object({
|
||||
achievements: z.number().nullable(),
|
||||
thinkingAbility: z.number().nullable(),
|
||||
communication: z.number().nullable(),
|
||||
cultureFit: z.number().nullable(),
|
||||
});
|
||||
export type AxisScores = z.infer<typeof axisScoresSchema>;
|
||||
|
||||
/** 評価観点の表示順。Pane 3 評価カードと Pane 4 モード 2 で共通に使う。 */
|
||||
export const AXIS_ORDER = axisKeySchema.options;
|
||||
|
||||
// ===== 選考フロー =====
|
||||
|
||||
/** 選考ステージのキー。`STAGE_ORDER` と一致する 4 段階。 */
|
||||
export const stageKeySchema = z.enum(["screening", "first", "second", "final"]);
|
||||
export type StageKey = z.infer<typeof stageKeySchema>;
|
||||
|
||||
/** ステージの実施ステータス。done = 実施済 / planned = 予定済 / pending = 未定。 */
|
||||
export const stageStatusSchema = z.enum(["done", "planned", "pending"]);
|
||||
export type StageStatus = z.infer<typeof stageStatusSchema>;
|
||||
|
||||
/**
|
||||
* 添付ファイル(Attachment)。Pane 4 モード 2 の「提出書類 / 文字起こし」両セクションで
|
||||
* 共通のファイル一覧として表示される(ADR-0010 §13 D75、ADR-0008 / ADR-0009 と整合)。
|
||||
*
|
||||
* `kind: "txt" | "pdf"` の discriminated union で、`txt` は `previewText` 必須、
|
||||
* `pdf` は持たない(雛形では PDF の中身プレビューはスコープ外。行は `disabled` で
|
||||
* 「プレビュー不可」バッジ表示、DL のみ可能)。`id` は React の `key` と一意性確保
|
||||
* のために必須。
|
||||
*/
|
||||
const txtAttachmentSchema = z.object({
|
||||
id: z.string(),
|
||||
kind: z.literal("txt"),
|
||||
name: z.string(),
|
||||
size: z.string(),
|
||||
previewText: z.string(),
|
||||
});
|
||||
const pdfAttachmentSchema = z.object({
|
||||
id: z.string(),
|
||||
kind: z.literal("pdf"),
|
||||
name: z.string(),
|
||||
size: z.string(),
|
||||
});
|
||||
export const attachmentSchema = z.discriminatedUnion("kind", [
|
||||
txtAttachmentSchema,
|
||||
pdfAttachmentSchema,
|
||||
]);
|
||||
export type Attachment = z.infer<typeof attachmentSchema>;
|
||||
|
||||
/**
|
||||
* 選考ステージごとのスコアカード(メタ情報 + 評価 + コメント + 要約 + 添付)。
|
||||
*
|
||||
* ADR-0010 §13 D75 / ADR-0008 で「提出書類 / 文字起こし」を `attachments` 統一型に
|
||||
* 集約した。旧 `transcript?: string[]` / `documents?: Array<{ name; size }>` は
|
||||
* 削除済(互換層なし、KISS)。書類選考も面接も同じ `Attachment[]` を持ち、
|
||||
* UI 側(`AttachmentList`)で同型のファイル一覧として描画される。
|
||||
*/
|
||||
export const scorecardSchema = z.object({
|
||||
stage: stageKeySchema,
|
||||
label: z.string(),
|
||||
date: z.string(),
|
||||
format: z.string(),
|
||||
interviewer: z.string(),
|
||||
decision: z.string().optional(),
|
||||
comment: z.string().optional(),
|
||||
summary: z.string().optional(),
|
||||
axisScores: axisScoresSchema,
|
||||
attachments: z.array(attachmentSchema),
|
||||
});
|
||||
export type Scorecard = z.infer<typeof scorecardSchema>;
|
||||
|
||||
/**
|
||||
* Pane 2 で表示するステージの並び順(左から右へ進行する選考フローと一致)。
|
||||
* `STAGE_ORDER` は派生計算(candidateGroups)と「+ 追加」UI の両方から参照する。
|
||||
*/
|
||||
export const STAGE_ORDER = stageKeySchema.options;
|
||||
|
||||
// ===== 候補者 =====
|
||||
|
||||
/**
|
||||
* 候補者の最上位データ。Pane 2 の所属グループは `stage` で決まる。
|
||||
* 各 scorecard の派生 status (`deriveStageStatus`) とは独立に持ち、
|
||||
* 「現在どのステージに居るか」を表す。
|
||||
*/
|
||||
export const candidateSchema = z.object({
|
||||
id: z.string(),
|
||||
profile: profileSchema,
|
||||
scorecards: z.array(scorecardSchema),
|
||||
stage: stageKeySchema,
|
||||
});
|
||||
export type Candidate = z.infer<typeof candidateSchema>;
|
||||
|
||||
// ===== JSON 全体用スキーマ =====
|
||||
|
||||
export const departmentsSchema = z.array(departmentSchema);
|
||||
export const candidatesSchema = z.array(candidateSchema);
|
||||
export const workspaceSchema = z.object({
|
||||
name: z.string(),
|
||||
icon: z.string(),
|
||||
});
|
||||
|
||||
// ===== Pane 4 の表示状態(SelectedDetail) =====
|
||||
|
||||
/**
|
||||
* Pane 4 に「何を開いているか」を表す型(ADR-0015 §9 大決定 G)。
|
||||
*
|
||||
* - `{ type: "stage"; stage }`: 選考ステージ詳細を表示中
|
||||
* - `null`: ステージ未選択(Pane 4 は畳み状態)
|
||||
*
|
||||
* 旧 `{ type: "profile" }` はモード 1 廃止(ADR-0015 §4 大決定 B)に伴い削除。
|
||||
*/
|
||||
export type SelectedDetail = { type: "stage"; stage: StageKey } | null;
|
||||
|
||||
// ===== Pane 2 の派生計算用 UI 表示型 =====
|
||||
// Workspace の派生計算 (candidateGroups) と CandidateListPane の props 型として共有する。
|
||||
// candidates 配列から生成される表示単位。
|
||||
//
|
||||
// `averageScore` は `lib/computed/scorecards.ts` の `getCandidateAverageScore` で
|
||||
// 派生計算した値を Workspace 側で詰めて渡す。Pane 2 候補者行の右端に
|
||||
// `★ 4.5` または `—`(未評価)として表示される。Pane 3 評価カードの「平均スコア」
|
||||
// と同じ意味(最新 done scorecard の axisScores 平均、ADR-0013)。
|
||||
|
||||
export type CandidateRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
averageScore: number | null;
|
||||
};
|
||||
|
||||
// Pane 2 のグループ表示単位(ステージごとに候補者行を束ねる)。
|
||||
// 候補者データは `INITIAL_CANDIDATES` を SSoT とし、`candidateGroups` で
|
||||
// 派生計算して CandidateListPane に props で渡す(Workspace 内で計算)。
|
||||
//
|
||||
// stage: StageKey は「+ ボタン」から `addCandidate(stage)` を呼ぶときの
|
||||
// 引数に使う(型安全に StageKey を渡すため)。日本語ラベルは label に分離。
|
||||
export type Group = {
|
||||
stage: StageKey;
|
||||
label: string;
|
||||
items: CandidateRow[];
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
Reference in New Issue
Block a user