SCALE — Build Lab
開発パターン · TYPESCRIPT LIBRARY

システム管理ストア

CATEGORY開発パターン TYPETypeScript Library EFFORT60〜180分 DIFFICULTY
PRIMARY CODE
ts · lib/system-mgmt-store.ts
// システム管理: PJT・タスクの localStorage CRUD フック。
// 1アカウント=1デバイス前提のため KV / D1 同期はしない。
// データ構造: System > Project (PJT) > Task

'use client';

import { useEffect, useState, useCallback } from 'react';

export type TaskStatus = 'todo' | 'requested' | 'fixing' | 'done';
export type TaskPriority = 'P0' | 'P1' | 'P2' | 'P3';

export const STATUS_LABEL: Record<TaskStatus, string> = {
  todo: '未依頼',
  requested: '依頼中',
  fixing: '修正中',
  done: '完了',
};

export const STATUS_COLOR: Record<TaskStatus, string> = {
  todo: '#94a3b8',
  requested: '#3b82f6',
  fixing: '#f59e0b',
  done: '#10b981',
};

export const STATUS_ORDER: TaskStatus[] = ['todo', 'requested', 'fixing', 'done'];

export const PRIORITY_COLOR: Record<TaskPriority, string> = {
  P0: '#ef4444',
  P1: '#f59e0b',
  P2: '#3b82f6',
  P3: '#94a3b8',
};

export interface SystemTask {
  id: string;
  systemId: string;
  projectId: string;
  title: string;
  rawNote: string;
  refinedPrompt: string;
  status: TaskStatus;
  priority: TaskPriority;
  createdAt: string;
  updatedAt: string;
  requestedAt?: string;
  completedAt?: string;
}

export interface SystemProject {
  id: string;
  systemId: string;
  name: string;
  order: number;
  createdAt: string;
}

const TASK_KEY = 'scale-base-system-mgmt-tasks-v1';
const PROJECT_KEY = 'scale-base-system-mgmt-projects-v1';
const DEFAULT_PROJECT_NAME = '全般';

function uid(): string {
  return Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
}

function nowIso(): string {
  return new Date().toISOString();
}

// ----- Migration: 旧ステータス → 新ステータス & projectId 補完 -----
function migrateTask(t: any): SystemTask {
  const oldStatus = t.status;
  let status: TaskStatus = 'todo';
  if (oldStatus === 'doing' || oldStatus === 'requested') status = 'requested';
  else if (oldStatus === 'fixing') status = 'fixing';
  else if (oldStatus === 'done') status = 'done';
  else status = 'todo';
  return {
    id: t.id || uid(),
    systemId: t.systemId,
    projectId: t.projectId || `__default__${t.systemId}`,
    title: t.title || '',
    rawNote: t.rawNote || '',
    refinedPrompt: t.refinedPrompt || '',
    status,
    priority: t.priority || 'P2',
    createdAt: t.createdAt || nowIso(),
    updatedAt: t.updatedAt || nowIso(),
    requestedAt: t.requestedAt,
    completedAt: t.completedAt,
  };
}

function readTasks(): SystemTask[] {
  if (typeof window === 'undefined') return [];
  try {
    const raw = localStorage.getItem(TASK_KEY);
    if (!raw) return [];
    const parsed = JSON.parse(raw);
    if (!Array.isArray(parsed)) return [];
    return parsed.map(migrateTask);
  } catch {
    return [];
  }
}

function writeTasks(tasks: SystemTask[]): void {
  if (typeof window === 'undefined') return;
  localStorage.setItem(TASK_KEY, JSON.stringify(tasks));
}

function readProjects(): SystemProject[] {
  if (typeof window === 'undefined') return [];
  try {
    const raw = localStorage.getItem(PROJECT_KEY);
    if (!raw) return [];
    const parsed = JSON.parse(raw);
    return Array.isArray(parsed) ? parsed : [];
  } catch {
    return [];
  }
}

function writeProjects(projects: SystemProject[]): void {
  if (typeof window === 'undefined') return;
  localStorage.setItem(PROJECT_KEY, JSON.stringify(projects));
}

// systemId に対するデフォルトPJTがなければ作成
function ensureDefaultProject(systemId: string): SystemProject {
  const all = readProjects();
  const existing = all.find((p) => p.systemId === systemId);
  if (existing) return existing;
  const def: SystemProject = {
    id: `__default__${systemId}`,
    systemId,
    name: DEFAULT_PROJECT_NAME,
    order: 0,
    createdAt: nowIso(),
  };
  writeProjects([...all, def]);
  return def;
}

export function useSystemMgmt(systemId: string) {
  const [tasks, setTasks] = useState<SystemTask[]>([]);
  const [projects, setProjects] = useState<SystemProject[]>([]);
  const [loaded, setLoaded] = useState(false);

  useEffect(() => {
    ensureDefaultProject(systemId);
    setTasks(readTasks().filter((t) => t.systemId === systemId));
    setProjects(readProjects().filter((p) => p.systemId === systemId).sort((a, b) => a.order - b.order));
    setLoaded(true);
  }, [systemId]);

  const refresh = useCallback(() => {
    setTasks(readTasks().filter((t) => t.systemId === systemId));
    setProjects(readProjects().filter((p) => p.systemId === systemId).sort((a, b) => a.order - b.order));
  }, [systemId]);

  // ---- Project ops ----
  const addProject = useCallback(
    (name: string) => {
      const trimmed = name.trim();
      if (!trimmed) return null;
      const all = readProjects();
      const existing = all.find((p) => p.systemId === systemId && p.name === trimmed);
      if (existing) return existing;
      const sysProjects = all.filter((p) => p.systemId === systemId);
      const next: SystemProject = {
        id: uid(),
        systemId,
        name: trimmed,
        order: sysProjects.length,
        createdAt: nowIso(),
      };
      writeProjects([...all, next]);
      refresh();
      return next;
    },
    [systemId, refresh]
  );

  const renameProject = useCallback(
    (projectId: string, name: string) => {
      const trimmed = name.trim();
      if (!trimmed) return;
      const all = readProjects();
      writeProjects(all.map((p) => (p.id === projectId ? { ...p, name: trimmed } : p)));
      refresh();
    },
    [refresh]
  );

  const deleteProject = useCallback(
    (projectId: string) => {
      const allProjects = readProjects();
      const target = allProjects.find((p) => p.id === projectId);
      if (!target) return;
      // タスクをデフォルトに移動
      const def = ensureDefaultProject(target.systemId);
      const allTasks = readTasks().map((t) =>
        t.projectId === projectId ? { ...t, projectId: def.id, updatedAt: nowIso() } : t
      );
      writeTasks(allTasks);
      writeProjects(allProjects.filter((p) => p.id !== projectId));
      refresh();
    },
    [refresh]
  );

  // ---- Task ops ----
  const addTask = useCallback(
    (input: { projectId: string; title: string; rawNote: string; refinedPrompt: string; priority?: TaskPriority }) => {
      const now = nowIso();
      const next: SystemTask = {
        id: uid(),
        systemId,
        projectId: input.projectId,
        title: input.title.trim(),
        rawNote: input.rawNote || '',
        refinedPrompt: input.refinedPrompt || '',
        status: 'todo',
        priority: input.priority || 'P2',
        createdAt: now,
        updatedAt: now,
      };
      writeTasks([next, ...readTasks()]);
      refresh();
      return next;
    },
    [systemId, refresh]
  );

  const addManyTasks = useCallback(
    (
      projectId: string,
      items: Array<{ title: string; rawNote: string; refinedPrompt: string; priority?: TaskPriority }>
    ) => {
      const now = nowIso();
      const created: SystemTask[] = items.map((it) => ({
        id: uid(),
        systemId,
        projectId,
        title: it.title.trim(),
        rawNote: it.rawNote || '',
        refinedPrompt: it.refinedPrompt || '',
        status: 'todo',
        priority: it.priority || 'P2',
        createdAt: now,
        updatedAt: now,
      }));
      writeTasks([...created, ...readTasks()]);
      refresh();
      return created;
    },
    [systemId, refresh]
  );

  const updateTask = useCallback(
    (id: string, patch: Partial<SystemTask>) => {
      const now = nowIso();
      const next = readTasks().map((t) => {
        if (t.id !== id) return t;
        const merged: SystemTask = { ...t, ...patch, updatedAt: now };
        // ステータス連動タイムスタンプ
        if (patch.status === 'done') merged.completedAt = now;
        else if (patch.status) merged.completedAt = undefined;
        if (patch.status === 'requested' && !merged.requestedAt) merged.requestedAt = now;
        return merged;
      });
      writeTasks(next);
      refresh();
    },
    [refresh]
  );

  const deleteTask = useCallback(
    (id: string) => {
      writeTasks(readTasks().filter((t) => t.id !== id));
      refresh();
    },
    [refresh]
  );

  // 一括ステータス更新(todo→requested など)
  const bulkUpdateStatus = useCallback(
    (ids: string[], status: TaskStatus) => {
      const idSet = new Set(ids);
      const now = nowIso();
      const next = readTasks().map((t) => {
        if (!idSet.has(t.id)) return t;
        const merged: SystemTask = { ...t, status, updatedAt: now };
        if (status === 'done') merged.completedAt = now;
        else merged.completedAt = undefined;
        if (status === 'requested' && !merged.requestedAt) merged.requestedAt = now;
        return merged;
      });
      writeTasks(next);
      refresh();
    },
    [refresh]
  );

  return {
    tasks,
    projects,
    loaded,
    addProject,
    renameProject,
    deleteProject,
    addTask,
    addManyTasks,
    updateTask,
    deleteTask,
    bulkUpdateStatus,
    refresh,
  };
}

// 一覧画面用: システムごとのタスク件数集計
export function countTasksBySystem(): Record<
  string,
  { total: number; todo: number; requested: number; fixing: number; done: number; lastUpdated?: string }
> {
  if (typeof window === 'undefined') return {};
  const all = readTasks();
  const counts: Record<
    string,
    { total: number; todo: number; requested: number; fixing: number; done: number; lastUpdated?: string }
  > = {};
  for (const t of all) {
    if (!counts[t.systemId])
      counts[t.systemId] = { total: 0, todo: 0, requested: 0, fixing: 0, done: 0 };
    counts[t.systemId].total++;
    counts[t.systemId][t.status]++;
    const last = counts[t.systemId].lastUpdated;
    if (!last || t.updatedAt > last) counts[t.systemId].lastUpdated = t.updatedAt;
  }
  return counts;
}

前提条件
Tailwind CSS v4TypeScript 5
USE CASES
  • システム管理画面

システム管理ストア

:LiTarget: 用途

システムの実行状態・ステータスを localStorage / KV にキャッシュして管理。

:LiSparkle: 特徴

  • localStorage連携
  • KV連携
  • 型安全
  • リアルタイム更新

:LiCode: 実コード(SCALE Base より自動抽出)

:LiInfo: lib/system-mgmt-store.ts の中身そのもの。コピペ即可。

// システム管理: PJT・タスクの localStorage CRUD フック。
// 1アカウント=1デバイス前提のため KV / D1 同期はしない。
// データ構造: System > Project (PJT) > Task

'use client';

import { useEffect, useState, useCallback } from 'react';

export type TaskStatus = 'todo' | 'requested' | 'fixing' | 'done';
export type TaskPriority = 'P0' | 'P1' | 'P2' | 'P3';

export const STATUS_LABEL: Record<TaskStatus, string> = {
  todo: '未依頼',
  requested: '依頼中',
  fixing: '修正中',
  done: '完了',
};

export const STATUS_COLOR: Record<TaskStatus, string> = {
  todo: '#94a3b8',
  requested: '#3b82f6',
  fixing: '#f59e0b',
  done: '#10b981',
};

export const STATUS_ORDER: TaskStatus[] = ['todo', 'requested', 'fixing', 'done'];

export const PRIORITY_COLOR: Record<TaskPriority, string> = {
  P0: '#ef4444',
  P1: '#f59e0b',
  P2: '#3b82f6',
  P3: '#94a3b8',
};

export interface SystemTask {
  id: string;
  systemId: string;
  projectId: string;
  title: string;
  rawNote: string;
  refinedPrompt: string;
  status: TaskStatus;
  priority: TaskPriority;
  createdAt: string;
  updatedAt: string;
  requestedAt?: string;
  completedAt?: string;
}

export interface SystemProject {
  id: string;
  systemId: string;
  name: string;
  order: number;
  createdAt: string;
}

const TASK_KEY = 'scale-base-system-mgmt-tasks-v1';
const PROJECT_KEY = 'scale-base-system-mgmt-projects-v1';
const DEFAULT_PROJECT_NAME = '全般';

function uid(): string {
  return Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
}

function nowIso(): string {
  return new Date().toISOString();
}

// ----- Migration: 旧ステータス → 新ステータス & projectId 補完 -----
function migrateTask(t: any): SystemTask {
  const oldStatus = t.status;
  let status: TaskStatus = 'todo';
  if (oldStatus === 'doing' || oldStatus === 'requested') status = 'requested';
  else if (oldStatus === 'fixing') status = 'fixing';
  else if (oldStatus === 'done') status = 'done';
  else status = 'todo';
  return {
    id: t.id || uid(),
    systemId: t.systemId,
    projectId: t.projectId || `__default__${t.systemId}`,
    title: t.title || '',
    rawNote: t.rawNote || '',
    refinedPrompt: t.refinedPrompt || '',
    status,
    priority: t.priority || 'P2',
    createdAt: t.createdAt || nowIso(),
    updatedAt: t.updatedAt || nowIso(),
    requestedAt: t.requestedAt,
    completedAt: t.completedAt,
  };
}

function readTasks(): SystemTask[] {
  if (typeof window === 'undefined') return [];
  try {
    const raw = localStorage.getItem(TASK_KEY);
    if (!raw) return [];
    const parsed = JSON.parse(raw);
    if (!Array.isArray(parsed)) return [];
    return parsed.map(migrateTask);
  } catch {
    return [];
  }
}

function writeTasks(tasks: SystemTask[]): void {
  if (typeof window === 'undefined') return;
  localStorage.setItem(TASK_KEY, JSON.stringify(tasks));
}

function readProjects(): SystemProject[] {
  if (typeof window === 'undefined') return [];
  try {
    const raw = localStorage.getItem(PROJECT_KEY);
    if (!raw) return [];
    const parsed = JSON.parse(raw);
    return Array.isArray(parsed) ? parsed : [];
  } catch {
    return [];
  }
}

function writeProjects(projects: SystemProject[]): void {
  if (typeof window === 'undefined') return;
  localStorage.setItem(PROJECT_KEY, JSON.stringify(projects));
}

// systemId に対するデフォルトPJTがなければ作成
function ensureDefaultProject(systemId: string): SystemProject {
  const all = readProjects();
  const existing = all.find((p) => p.systemId === systemId);
  if (existing) return existing;
  const def: SystemProject = {
    id: `__default__${systemId}`,
    systemId,
    name: DEFAULT_PROJECT_NAME,
    order: 0,
    createdAt: nowIso(),
  };
  writeProjects([...all, def]);
  return def;
}

export function useSystemMgmt(systemId: string) {
  const [tasks, setTasks] = useState<SystemTask[]>([]);
  const [projects, setProjects] = useState<SystemProject[]>([]);
  const [loaded, setLoaded] = useState(false);

  useEffect(() => {
    ensureDefaultProject(systemId);
    setTasks(readTasks().filter((t) => t.systemId === systemId));
    setProjects(readProjects().filter((p) => p.systemId === systemId).sort((a, b) => a.order - b.order));
    setLoaded(true);
  }, [systemId]);

  const refresh = useCallback(() => {
    setTasks(readTasks().filter((t) => t.systemId === systemId));
    setProjects(readProjects().filter((p) => p.systemId === systemId).sort((a, b) => a.order - b.order));
  }, [systemId]);

  // ---- Project ops ----
  const addProject = useCallback(
    (name: string) => {
      const trimmed = name.trim();
      if (!trimmed) return null;
      const all = readProjects();
      const existing = all.find((p) => p.systemId === systemId && p.name === trimmed);
      if (existing) return existing;
      const sysProjects = all.filter((p) => p.systemId === systemId);
      const next: SystemProject = {
        id: uid(),
        systemId,
        name: trimmed,
        order: sysProjects.length,
        createdAt: nowIso(),
      };
      writeProjects([...all, next]);
      refresh();
      return next;
    },
    [systemId, refresh]
  );

  const renameProject = useCallback(
    (projectId: string, name: string) => {
      const trimmed = name.trim();
      if (!trimmed) return;
      const all = readProjects();
      writeProjects(all.map((p) => (p.id === projectId ? { ...p, name: trimmed } : p)));
      refresh();
    },
    [refresh]
  );

  const deleteProject = useCallback(
    (projectId: string) => {
      const allProjects = readProjects();
      const target = allProjects.find((p) => p.id === projectId);
      if (!target) return;
      // タスクをデフォルトに移動
      const def = ensureDefaultProject(target.systemId);
      const allTasks = readTasks().map((t) =>
        t.projectId === projectId ? { ...t, projectId: def.id, updatedAt: nowIso() } : t
      );
      writeTasks(allTasks);
      writeProjects(allProjects.filter((p) => p.id !== projectId));
      refresh();
    },
    [refresh]
  );

  // ---- Task ops ----
  const addTask = useCallback(
    (input: { projectId: string; title: string; rawNote: string; refinedPrompt: string; priority?: TaskPriority }) => {
      const now = nowIso();
      const next: SystemTask = {
        id: uid(),
        systemId,
        projectId: input.projectId,
        title: input.title.trim(),
        rawNote: input.rawNote || '',
        refinedPrompt: input.refinedPrompt || '',
        status: 'todo',
        priority: input.priority || 'P2',
        createdAt: now,
        updatedAt: now,
      };
      writeTasks([next, ...readTasks()]);
      refresh();
      return next;
    },
    [systemId, refresh]
  );

  const addManyTasks = useCallback(
    (
      projectId: string,
      items: Array<{ title: string; rawNote: string; refinedPrompt: string; priority?: TaskPriority }>
    ) => {
      const now = nowIso();
      const created: SystemTask[] = items.map((it) => ({
        id: uid(),
        systemId,
        projectId,
        title: it.title.trim(),
        rawNote: it.rawNote || '',
        refinedPrompt: it.refinedPrompt || '',
        status: 'todo',
        priority: it.priority || 'P2',
        createdAt: now,
        updatedAt: now,
      }));
      writeTasks([...created, ...readTasks()]);
      refresh();
      return created;
    },
    [systemId, refresh]
  );

  const updateTask = useCallback(
    (id: string, patch: Partial<SystemTask>) => {
      const now = nowIso();
      const next = readTasks().map((t) => {
        if (t.id !== id) return t;
        const merged: SystemTask = { ...t, ...patch, updatedAt: now };
        // ステータス連動タイムスタンプ
        if (patch.status === 'done') merged.completedAt = now;
        else if (patch.status) merged.completedAt = undefined;
        if (patch.status === 'requested' && !merged.requestedAt) merged.requestedAt = now;
        return merged;
      });
      writeTasks(next);
      refresh();
    },
    [refresh]
  );

  const deleteTask = useCallback(
    (id: string) => {
      writeTasks(readTasks().filter((t) => t.id !== id));
      refresh();
    },
    [refresh]
  );

  // 一括ステータス更新(todo→requested など)
  const bulkUpdateStatus = useCallback(
    (ids: string[], status: TaskStatus) => {
      const idSet = new Set(ids);
      const now = nowIso();
      const next = readTasks().map((t) => {
        if (!idSet.has(t.id)) return t;
        const merged: SystemTask = { ...t, status, updatedAt: now };
        if (status === 'done') merged.completedAt = now;
        else merged.completedAt = undefined;
        if (status === 'requested' && !merged.requestedAt) merged.requestedAt = now;
        return merged;
      });
      writeTasks(next);
      refresh();
    },
    [refresh]
  );

  return {
    tasks,
    projects,
    loaded,
    addProject,
    renameProject,
    deleteProject,
    addTask,
    addManyTasks,
    updateTask,
    deleteTask,
    bulkUpdateStatus,
    refresh,
  };
}

// 一覧画面用: システムごとのタスク件数集計
export function countTasksBySystem(): Record<
  string,
  { total: number; todo: number; requested: number; fixing: number; done: number; lastUpdated?: string }
> {
  if (typeof window === 'undefined') return {};
  const all = readTasks();
  const counts: Record<
    string,
    { total: number; todo: number; requested: number; fixing: number; done: number; lastUpdated?: string }
  > = {};
  for (const t of all) {
    if (!counts[t.systemId])
      counts[t.systemId] = { total: 0, todo: 0, requested: 0, fixing: 0, done: 0 };
    counts[t.systemId].total++;
    counts[t.systemId][t.status]++;
    const last = counts[t.systemId].lastUpdated;
    if (!last || t.updatedAt > last) counts[t.systemId].lastUpdated = t.updatedAt;
  }
  return counts;
}

:LiFolder: ソースファイルのパス

/Users/oogushiyuuki/Library/CloudStorage/GoogleDrive-y-ogushi@scale-group.co.jp/マイドライブ/AI/scale-base/lib/system-mgmt-store.ts

:LiHandPointer: 使い方

対象プロジェクトに該当ファイルをコピーして、props を流し込むだけ。

:LiAlertCircle: 注意事項

  • 依存パッケージを忘れず追加