BizLayout

型番 raci-matrix

RACIマトリクス(責任分担・統制診断)

活動と役割の交点へR/A/C/Iを配置し、最終責任者Aの不在・重複と実行責任者Rの不在をデータから自動検出する。責任分担表、統制課題ledger、mobile activity ledgerが同じ活動・役割順・記号を共有し、診断を元の根拠へ戻して読める

raci · governance · responsibility · operating-model · data-driven · interactive

LIVE SAMPLE

プレビュー

補足の開閉・表示切替を試せます

運用移管の責任設計 — 障害一次判断の最終責任者が未配置

新基幹システム運用モデル案(R:実行、A:最終責任、C:相談、I:報告)

01

リリース承認

業務部門C
サービス責任者A
運用チームR
保守ベンダーC
セキュリティI
02

障害一次判断A不在

業務部門I
サービス責任者·
運用チームR
保守ベンダーC
セキュリティC
03

復旧作業

業務部門I
サービス責任者A
運用チームR
保守ベンダーR
セキュリティC
04

恒久対策承認

業務部門C
サービス責任者A
運用チームR
保守ベンダーC
セキュリティC
05

月次SLA報告

業務部門I
サービス責任者A
運用チームR
保守ベンダーC
セキュリティI

R 実行 · A 最終責任 · C 相談 · I 報告

補足を確認5

リリース承認

サービス責任者が業務影響と試験結果を踏まえて最終承認

障害一次判断

重大度判定の最終責任が未定義。15分以内の判断目標に影響

復旧作業

運用チームが指揮し、保守ベンダーが技術作業を実施

恒久対策承認

再発防止策と残存リスクをサービス責任者が承認

月次SLA報告

SLA、障害傾向、改善アクションを月次レビュー

こういうときに使う

  • プロジェクト・運用・移管体制の責任境界を明確にする場合
  • 承認者や実行担当の不在・重複を診断したい場合
  • 選択肢の比較ではなく、活動ごとの役割分担を示す場合
  • 数値スコアではなくRACI記号の整合性を確認する場合

ALTERNATIVES

条件を変えて探す →

データスキーマ

title
string — スライドタイトル。最優先の責任欠陥を結論として書く
subtitle
string(任意)— 対象組織、時点、RACI定義など
roles
{ id, label }[] — 役割・部門(2〜6件)
activities
{ label, assignments, note? }[] — 3〜8件。assignmentsはrolesと同順・同数のR/A/C/I/-

サンプルコード

template.tsx525 行
import { useId } from "react";
import type {
  RaciActivity,
  RaciMark,
  RaciMatrixProps,
} from "./schema";

export type RaciIssueCode =
  | "missing-accountable"
  | "multiple-accountable"
  | "missing-responsible";
export type RaciIssue = {
  activityIndex: number;
  activityLabel: string;
  code: RaciIssueCode;
};

export function activityAuditSummary(activity: {
  assignments: readonly RaciMark[];
}) {
  return {
    accountable: activity.assignments.filter((mark) => mark === "A").length,
    responsible: activity.assignments.filter((mark) => mark === "R").length,
  };
}

export function auditActivities(
  activities: readonly {
    label: string;
    assignments: readonly RaciMark[];
  }[],
): RaciIssue[] {
  return activities.flatMap((activity, activityIndex) => {
    const issues: RaciIssue[] = [];
    const { accountable, responsible } = activityAuditSummary(activity);
    if (accountable === 0) {
      issues.push({
        activityIndex,
        activityLabel: activity.label,
        code: "missing-accountable",
      });
    }
    if (accountable > 1) {
      issues.push({
        activityIndex,
        activityLabel: activity.label,
        code: "multiple-accountable",
      });
    }
    if (responsible === 0) {
      issues.push({
        activityIndex,
        activityLabel: activity.label,
        code: "missing-responsible",
      });
    }
    return issues;
  });
}

export function issueEvidenceRoleIndexes(
  activity: { assignments: readonly RaciMark[] },
  issue: RaciIssue,
) {
  if (issue.code !== "multiple-accountable") return [];
  return activity.assignments.flatMap((mark, roleIndex) =>
    mark === "A" ? [roleIndex] : [],
  );
}

export function issueLabel(code: RaciIssueCode) {
  return (
    {
      "missing-accountable": "A不在",
      "multiple-accountable": "A重複",
      "missing-responsible": "R不在",
    } as const
  )[code];
}

const issueDescription = (code: RaciIssueCode) =>
  (
    {
      "missing-accountable": "最終説明責任を負う役割が定義されていない",
      "multiple-accountable": "最終判断者が複数で、意思決定が競合する",
      "missing-responsible": "実行責任を担う役割が定義されていない",
    } as const
  )[code];

const MARK_STYLES: Record<RaciMark, string> = {
  R: "bg-tertiary text-on-primary",
  A: "bg-primary text-on-primary",
  C: "bg-neutral text-secondary",
  I: "border border-outline bg-surface text-secondary",
  "-": "text-outline",
};

const stagger = (index: number) =>
  `calc(var(--stagger-step) * ${index})`;

function Mark({
  mark,
  activityIndex,
  roleLabel,
  evidence = false,
}: {
  mark: RaciMark;
  activityIndex: number;
  roleLabel: string;
  evidence?: boolean;
}) {
  return (
    <span
      tabIndex={0}
      data-raci-hotspot={activityIndex}
      data-raci-evidence={evidence ? "true" : undefined}
      aria-label={`${roleLabel}: ${mark === "-" ? "割当なし" : mark}`}
      className={`inline-flex size-8 items-center justify-center rounded-full font-mono text-label outline-none transition-[color,background-color,box-shadow,opacity] focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 ${MARK_STYLES[mark]} ${evidence ? "ring-2 ring-tertiary ring-offset-2" : ""}`}
    >
      {mark === "-" ? "·" : mark}
    </span>
  );
}

function ActivityIssueLabels({ issues }: { issues: readonly RaciIssue[] }) {
  if (issues.length === 0) return null;
  return (
    <span className="ml-2 inline-flex flex-wrap gap-1 align-middle">
      {issues.map((issue) => (
        <span
          key={issue.code}
          className="border-l-2 border-tertiary pl-1.5 font-mono text-label text-tertiary"
        >
          {issueLabel(issue.code)}
        </span>
      ))}
    </span>
  );
}

function MobileActivityRecord({
  activity,
  activityIndex,
  roles,
  issues,
  isDefaultPriority,
}: {
  activity: RaciActivity;
  activityIndex: number;
  roles: RaciMatrixProps["roles"];
  issues: readonly RaciIssue[];
  isDefaultPriority: boolean;
}) {
  return (
    <article
      data-raci-activity-record={activityIndex}
      data-raci-surface={activityIndex}
      data-raci-default-priority={isDefaultPriority ? "true" : undefined}
      className={`animate-rise border-b px-1 py-4 transition-[background-color,box-shadow,opacity] first:border-t ${
        isDefaultPriority
          ? "border-tertiary bg-gradient-to-r from-tertiary/10 to-surface"
          : "border-outline"
      }`}
      style={{ animationDelay: stagger(activityIndex) }}
    >
      <div
        tabIndex={0}
        data-raci-hotspot={activityIndex}
        className="flex items-start gap-2 outline-none focus-visible:ring-2 focus-visible:ring-primary"
      >
        <span className="pt-0.5 font-mono text-label text-secondary">
          {String(activityIndex + 1).padStart(2, "0")}
        </span>
        <h2 className="min-w-0 text-body-sm">
          {activity.label}
          <ActivityIssueLabels issues={issues} />
        </h2>
      </div>
      <div className="mt-3 grid grid-cols-2 gap-x-4 gap-y-2 pl-7">
        {activity.assignments.map((mark, roleIndex) => (
          <div
            key={roles[roleIndex].id}
            data-raci-role-index={roleIndex}
            data-raci-mark={mark}
            className="flex min-w-0 items-center justify-between gap-2 border-t border-outline/60 pt-2"
          >
            <span className="min-w-0 text-label text-secondary">
              {roles[roleIndex].label}
            </span>
            <Mark
              mark={mark}
              activityIndex={activityIndex}
              roleLabel={roles[roleIndex].label}
            />
          </div>
        ))}
      </div>
    </article>
  );
}

export default function RaciMatrix({
  title,
  subtitle,
  roles,
  activities,
  instanceId,
}: RaciMatrixProps) {
  const autoId = useId();
  const uid =
    (instanceId ?? autoId).replace(/[^a-zA-Z0-9_-]/g, "") || "raci";
  const issues = auditActivities(activities);
  const firstIssueActivity = issues[0]?.activityIndex;
  const notes = activities.filter((activity) => activity.note);
  const issuesByActivity = activities.map((_, activityIndex) =>
    issues.filter((issue) => issue.activityIndex === activityIndex),
  );
  const interactionCss = activities
    .map(
      (_, activityIndex) => `
[data-raci-root="${uid}"]:has([data-raci-hotspot="${activityIndex}"]:is(:hover,:focus-visible)) [data-raci-surface="${activityIndex}"] {
  background-color: color-mix(in srgb, var(--color-primary) 9%, var(--color-surface));
  box-shadow: inset 3px 0 0 var(--color-primary);
  color: var(--color-on-surface);
  opacity: 1;
}
[data-raci-root="${uid}"]:has([data-raci-hotspot="${activityIndex}"]:is(:hover,:focus-visible)) [data-raci-default-priority="true"]:not([data-raci-surface="${activityIndex}"]) {
  filter: saturate(.28) brightness(1.08);
}`,
    )
    .join("\n");

  return (
    <section
      data-raci-root={uid}
      className="group/raci mx-auto max-w-slide bg-surface p-12 font-sans text-on-surface"
    >
      <style>{interactionCss}</style>
      <input
        id={`${uid}-matrix`}
        name={`${uid}-view`}
        type="radio"
        defaultChecked
        className="raci-matrix-view sr-only"
      />
      <input
        id={`${uid}-issues`}
        name={`${uid}-view`}
        type="radio"
        className="raci-issues-view sr-only"
      />
      <header className="mb-7 flex flex-wrap items-end justify-between gap-4">
        <div className="max-w-3xl">
          <h1 className="text-h1">{title}</h1>
          {subtitle && (
            <p className="mt-2 text-body-sm text-secondary">{subtitle}</p>
          )}
        </div>
        <div className="flex rounded-sm border border-outline bg-surface-subtle p-1 shadow-card">
          <label
            htmlFor={`${uid}-matrix`}
            className="cursor-pointer rounded-sm px-4 py-1.5 text-label text-secondary group-has-[.raci-matrix-view:checked]/raci:bg-primary group-has-[.raci-matrix-view:checked]/raci:text-on-primary"
          >
            責任分担
          </label>
          <label
            htmlFor={`${uid}-issues`}
            className="cursor-pointer rounded-sm px-4 py-1.5 text-label text-secondary group-has-[.raci-issues-view:checked]/raci:bg-primary group-has-[.raci-issues-view:checked]/raci:text-on-primary"
          >
            統制課題
          </label>
        </div>
      </header>

      <div
        data-testid="raci-matrix"
        className="block group-has-[.raci-issues-view:checked]/raci:hidden"
      >
        <div data-raci-view="matrix-desktop" className="hidden sm:block">
          <table className="w-full table-fixed border-collapse">
            <colgroup>
              <col className="w-[29%]" />
              {roles.map((role) => (
                <col key={role.id} />
              ))}
            </colgroup>
            <thead>
              <tr className="border-y border-outline">
                <th className="px-3 py-3 text-left font-mono text-label text-secondary">
                  ACTIVITY
                </th>
                {roles.map((role) => (
                  <th
                    key={role.id}
                    className="px-1 py-3 text-center text-label text-secondary"
                  >
                    {role.label}
                  </th>
                ))}
              </tr>
            </thead>
            <tbody>
              {activities.map((activity, activityIndex) => {
                const activityIssues = issuesByActivity[activityIndex];
                const isDefaultPriority =
                  activityIndex === firstIssueActivity;
                return (
                  <tr
                    key={activityIndex}
                    data-raci-activity-record={activityIndex}
                    data-raci-surface={activityIndex}
                    data-raci-default-priority={
                      isDefaultPriority ? "true" : undefined
                    }
                    data-has-issue={
                      activityIssues.length > 0 ? "true" : "false"
                    }
                    className={`animate-rise border-b transition-[background-color,box-shadow,opacity] ${
                      isDefaultPriority
                        ? "border-tertiary bg-gradient-to-r from-tertiary/10 to-surface"
                        : "border-outline"
                    }`}
                    style={{ animationDelay: stagger(activityIndex) }}
                  >
                    <th scope="row" className="px-3 py-3 text-left">
                      <span
                        tabIndex={0}
                        data-raci-hotspot={activityIndex}
                        className="block text-body-sm outline-none focus-visible:ring-2 focus-visible:ring-primary"
                      >
                        {activity.label}
                        <ActivityIssueLabels issues={activityIssues} />
                      </span>
                    </th>
                    {activity.assignments.map((mark, roleIndex) => (
                      <td
                        key={roles[roleIndex].id}
                        data-raci-role-index={roleIndex}
                        data-raci-mark={mark}
                        className="px-1 py-3 text-center"
                      >
                        <Mark
                          mark={mark}
                          activityIndex={activityIndex}
                          roleLabel={roles[roleIndex].label}
                        />
                      </td>
                    ))}
                  </tr>
                );
              })}
            </tbody>
          </table>
        </div>

        <div data-raci-view="matrix-mobile" className="sm:hidden">
          {activities.map((activity, activityIndex) => (
            <MobileActivityRecord
              key={activityIndex}
              activity={activity}
              activityIndex={activityIndex}
              roles={roles}
              issues={issuesByActivity[activityIndex]}
              isDefaultPriority={activityIndex === firstIssueActivity}
            />
          ))}
        </div>
        <p className="mt-3 text-right font-mono text-label text-secondary">
          R 実行 · A 最終責任 · C 相談 · I 報告
        </p>
      </div>

      <div
        data-testid="raci-issues"
        data-raci-view="issues"
        className="hidden group-has-[.raci-issues-view:checked]/raci:block"
      >
        {issues.length === 0 ? (
          <div className="border-y border-outline bg-surface-subtle p-8 text-center">
            <p className="text-h3">
              責任上の欠陥は検出されませんでした
            </p>
            <p className="mt-2 text-body-sm text-secondary">
              全活動にAが1件、Rが1件以上あります
            </p>
          </div>
        ) : (
          <div>
            {issues.map((issue, issueIndex) => {
              const activity = activities[issue.activityIndex];
              const summary = activityAuditSummary(activity);
              const evidenceIndexes = new Set(
                issueEvidenceRoleIndexes(activity, issue),
              );
              const absence =
                issue.code === "missing-accountable"
                  ? { mark: "A", count: summary.accountable }
                  : issue.code === "missing-responsible"
                    ? { mark: "R", count: summary.responsible }
                    : null;
              return (
                <article
                  key={`${issue.activityIndex}-${issue.code}`}
                  data-priority={issueIndex === 0 ? "true" : "false"}
                  data-raci-default-priority={
                    issueIndex === 0 ? "true" : undefined
                  }
                  data-raci-issue-record={issueIndex}
                  data-raci-issue-activity={issue.activityIndex}
                  data-raci-surface={issue.activityIndex}
                  className={`animate-rise border-b px-1 py-5 transition-[background-color,box-shadow,filter,opacity] first:border-t ${
                    issueIndex === 0
                      ? "border-tertiary bg-gradient-to-r from-tertiary/15 via-surface to-surface"
                      : "border-outline bg-surface"
                  }`}
                  style={{ animationDelay: stagger(issueIndex) }}
                >
                  <div className="grid gap-4 lg:grid-cols-[minmax(11rem,1.3fr)_minmax(20rem,3fr)_minmax(13rem,1.5fr)] lg:items-center">
                    <div
                      tabIndex={0}
                      data-raci-hotspot={issue.activityIndex}
                      className="outline-none focus-visible:ring-2 focus-visible:ring-primary"
                    >
                      <div className="flex items-center gap-2">
                        <span className="font-mono text-label text-secondary">
                          {String(issueIndex + 1).padStart(2, "0")}
                        </span>
                        <span className="border-l-2 border-tertiary pl-2 font-mono text-label text-tertiary">
                          {issueLabel(issue.code)}
                        </span>
                      </div>
                      <h2 className="mt-1 text-body-sm">
                        {issue.activityLabel}
                      </h2>
                    </div>

                    <div className="flex flex-wrap gap-x-4 gap-y-3">
                      {activity.assignments.map((mark, roleIndex) => {
                        const evidence = evidenceIndexes.has(roleIndex);
                        return (
                          <div
                            key={roles[roleIndex].id}
                            data-raci-role-index={roleIndex}
                            data-raci-mark={mark}
                            className={`flex min-w-28 flex-1 items-center justify-between gap-2 border-t pt-2 ${
                              evidence
                                ? "border-tertiary"
                                : "border-outline"
                            }`}
                          >
                            <span className="text-label text-secondary">
                              {roles[roleIndex].label}
                            </span>
                            <Mark
                              mark={mark}
                              activityIndex={issue.activityIndex}
                              roleLabel={roles[roleIndex].label}
                              evidence={evidence}
                            />
                          </div>
                        );
                      })}
                    </div>

                    <div className="border-l-2 border-outline pl-3">
                      <p className="text-body-sm text-secondary">
                        {issueDescription(issue.code)}
                      </p>
                      {absence && (
                        <span
                          data-raci-absence={absence.mark}
                          className="mt-2 inline-flex items-baseline gap-2 font-mono text-label text-tertiary"
                        >
                          <strong className="text-h3">
                            {absence.mark} {absence.count}
                          </strong>
                          <span>/ 必要数 1</span>
                        </span>
                      )}
                      {issue.code === "multiple-accountable" && (
                        <span className="mt-2 block font-mono text-label text-tertiary">
                          A {summary.accountable} / 必要数 1
                        </span>
                      )}
                    </div>
                  </div>
                </article>
              );
            })}
          </div>
        )}
      </div>

      {notes.length > 0 && (
        <details
          data-inline-disclosure="true"
          className="group mt-6 rounded-lg border border-outline bg-surface-subtle"
        >
          <summary className="flex cursor-pointer list-none items-center px-4 py-3 text-label text-secondary focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tertiary">
            <span>補足を確認</span>
            <span className="ml-auto font-mono">{notes.length}件</span>
            <span
              aria-hidden="true"
              className="ml-2 text-tertiary transition-transform group-open:rotate-45"
            >
              +
            </span>
          </summary>
          <div className="grid gap-3 border-t border-outline p-4 sm:grid-cols-2">
            {notes.map((activity, index) => (
              <article
                key={index}
                data-note-item="true"
                className="rounded-md bg-surface p-4"
              >
                <h3 className="text-label">{activity.label}</h3>
                <p className="mt-2 text-body-sm">{activity.note}</p>
              </article>
            ))}
          </div>
        </details>
      )}
    </section>
  );
}
schema.ts47 行
import { z } from "zod";

export const raciMarkSchema = z.enum(["R", "A", "C", "I", "-"]);

export const raciRoleSchema = z
  .object({
    id: z.string().min(1).describe("役割の一意なID"),
    label: z.string().min(1).describe("役割・部門名"),
  })
  .strict();

export const raciActivitySchema = z
  .object({
    label: z.string().min(1).describe("業務・意思決定・成果物名"),
    assignments: z.array(raciMarkSchema).describe("rolesと同じ順・同じ件数のR/A/C/I/-"),
    note: z.string().optional().describe("責任境界、判断条件、例外などの補足"),
  })
  .strict();

export const schema = z
  .object({
    title: z.string().describe("スライドタイトル。最優先の責任欠陥を結論として書く"),
    subtitle: z.string().optional().describe("対象組織、時点、RACI定義など"),
    roles: z.array(raciRoleSchema).min(2).max(6).describe("役割・部門(2〜6件)"),
    activities: z.array(raciActivitySchema).min(3).max(8).describe("活動・判断(3〜8件)"),
    instanceId: z.string().min(1).optional().describe("表示切替radioの識別子"),
  })
  .strict()
  .superRefine((data, ctx) => {
    const roleIds = new Set<string>();
    data.roles.forEach((role, index) => {
      if (roleIds.has(role.id)) {
        ctx.addIssue({ code: "custom", message: "role.id は一意にすること", path: ["roles", index, "id"] });
      }
      roleIds.add(role.id);
    });
    data.activities.forEach((activity, index) => {
      if (activity.assignments.length !== data.roles.length) {
        ctx.addIssue({ code: "custom", message: `assignments は roles と同じ${data.roles.length}件にすること`, path: ["activities", index, "assignments"] });
      }
    });
  });

export type RaciMark = z.infer<typeof raciMarkSchema>;
export type RaciRole = z.infer<typeof raciRoleSchema>;
export type RaciActivity = z.infer<typeof raciActivitySchema>;
export type RaciMatrixProps = z.infer<typeof schema>;
sample-data.json18 行
{
  "title": "運用移管の責任設計 — 障害一次判断の最終責任者が未配置",
  "subtitle": "新基幹システム運用モデル案(R:実行、A:最終責任、C:相談、I:報告)",
  "roles": [
    { "id": "business", "label": "業務部門" },
    { "id": "service", "label": "サービス責任者" },
    { "id": "ops", "label": "運用チーム" },
    { "id": "vendor", "label": "保守ベンダー" },
    { "id": "security", "label": "セキュリティ" }
  ],
  "activities": [
    { "label": "リリース承認", "assignments": ["C", "A", "R", "C", "I"], "note": "サービス責任者が業務影響と試験結果を踏まえて最終承認" },
    { "label": "障害一次判断", "assignments": ["I", "-", "R", "C", "C"], "note": "重大度判定の最終責任が未定義。15分以内の判断目標に影響" },
    { "label": "復旧作業", "assignments": ["I", "A", "R", "R", "C"], "note": "運用チームが指揮し、保守ベンダーが技術作業を実施" },
    { "label": "恒久対策承認", "assignments": ["C", "A", "R", "C", "C"], "note": "再発防止策と残存リスクをサービス責任者が承認" },
    { "label": "月次SLA報告", "assignments": ["I", "A", "R", "C", "I"], "note": "SLA、障害傾向、改善アクションを月次レビュー" }
  ]
}