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

タスク API レイヤー

CATEGORY開発パターン TYPETypeScript Library EFFORT240〜600分 DIFFICULTY
PRIMARY CODE
tsx · lib/tasks-api.tsx
'use client';

import { createContext, useContext, useState, useEffect, useCallback, useRef, type ReactNode } from 'react';

// =========== CONFIG ===========
const API_BASE = 'https://scale-task-cron.y-ogushi.workers.dev';

// =========== TYPES ===========
export interface DateChange {
  from: string;
  to: string;
  user: string;
  time: string;
  reason: string;
}

export interface StatusChange {
  from: string;
  to: string;
  user: string;
  time: string;
  reason: string;
}

export interface TaskComment {
  user: string;
  text: string;
  time: string;
}

export interface Task {
  id: string;
  name: string;
  assignee: string;
  // 複数担当対応(assignees)。後方互換のため単数 assignee も維持し、両方を join('、') 等で統合可能。
  assignees?: string[];
  requester: string;
  due: string;
  originalDue: string;
  priority: number;
  status: string;
  type: string;
  project: string;
  memo: string;
  link: string;
  // 詳細説明(何のタスクか・完了条件・注意点)。Slack連携時にClaudeで自動生成可
  description?: string;
  // 関連リンク(複数)。link は単数の後方互換用、links は配列
  links?: { label?: string; url: string }[];
  // 想定所要時間(分)
  estimateMinutes?: number;
  // 由来(どこから追加されたか): 'manual' | 'morning' | 'active' | 'end' | 'slack-bot' | 'routine'
  origin?: string;
  todayFlag: boolean;
  dateChanges: DateChange[];
  statusChanges: StatusChange[];
  editHistory: { field: string; from: string; to: string; user: string; time: string }[];
  comments: TaskComment[];
  createdBy: string;
  createdAt: string;
  completedDate: string | null;
  confirmedBy: string | null;
  fmt?: string;
}

export interface Project {
  id: string;
  name: string;
  owner: string;
  status: string;
  startDate: string;
  endDate: string;
  memo: string;
  goal: string;
}

export interface BottomTask {
  id: string;
  name: string;
  assignee: string;
  frequency: string;
  due: string; // 後方互換。現在は dayOfWeek / dayOfMonth を優先
  dayOfWeek?: number; // 0=日 〜 6=土(weekly / biweekly 用)
  dayOfMonth?: number; // 1-31 または -1=月末(monthly 用)
  project?: string; // 紐付くPJTのID
  memo: string;
  link?: string; // 関連URL(LinkedInアカウント / 使用ツール等)。作業中画面で🔗ボタン表示
  estimateMinutes?: number; // 目安作業時間(分)。朝日報/作業開始の合計表示で使用
  lastDone: string | null;
  createdAt: string;
}

export interface TaskMember {
  id: string;
  name: string;
  pass: string;
  role: string;
  icon?: string; // 絵文字アイコン(例: '🐻')。未設定時は名前の頭文字
  iconBg?: string; // アイコン背景色(HEX)。未設定時は名前ベースのグラデ
}

export const MEMBER_EMOJI_PRESETS = [
  '🐻', '🐼', '🦊', '🐱', '🐶', '🐰', '🐯', '🐸', '🐷', '🐵',
  '🦄', '🦁', '🐨', '🐹', '🐺', '🐧', '🦉', '🐙', '🦝', '🐠',
  '🦋', '🐝', '🐢', '🦖', '🐳', '🦈', '🐉', '🦚',
  '🍎', '🍊', '🍋', '🍓', '🥑', '🍕', '🍔', '🍩', '🍪', '🎂',
  '⭐️', '✨', '🔥', '❄️', '💎', '🌙', '☀️', '⚡️', '🌈', '🎯',
  '🚀', '🤖', '👻', '🎨', '🎪', '🏆', '💡', '🎮', '📚', '🎸',
];

export const MEMBER_BG_PRESETS = [
  '#fde2d3', '#fdd4e8', '#e8d9f7', '#d1ebf5', '#d4f0dd',
  '#f7ecc9', '#edd5f2', '#d4def0', '#f0dbc9', '#e0e0e5',
  '#ffc8a8', '#ffcadb', '#d4c0ea', '#b5ddee', '#bfe6ca',
  '#f3ddaa', '#e2b9ee', '#b9ccea', '#e8bfa0', '#c9c9d0',
];

export interface AuditEntry {
  id: string;
  user: string;
  action: string;
  detail: string;
  taskId: string;
  time: string;
}

export interface SlackScanResult {
  tasks: { name: string; assignee: string; due: string; priority: number; channel: string; summary: string; source_user: string }[];
  channel: string;
  messages: number;
  scannedAt: string;
  addedCount?: number;
  error?: string;
}

type HistoryEntryType = 'tasks' | 'projects' | 'bottom_tasks' | 'members';
interface HistoryEntry {
  type: HistoryEntryType;
  before: Task[] | Project[] | BottomTask[] | TaskMember[];
  time: number;
  label?: string;
}

// =========== CONSTANTS ===========
export const STATUSES = ['未着手', '進行中', '確認待ち', '完了', '保留', '中止'];
export const STATUS_COLORS: Record<string, string> = {
  '未着手': 'text-gray-400 bg-gray-500/10 border-gray-500/30',
  '進行中': 'text-blue-400 bg-blue-500/10 border-blue-500/30',
  '確認待ち': 'text-orange-400 bg-orange-500/10 border-orange-500/30',
  '完了': 'text-green-400 bg-green-500/10 border-green-500/30',
  '保留': 'text-purple-400 bg-purple-500/10 border-purple-500/30',
  '中止': 'text-red-400 bg-red-500/10 border-red-500/30',
};
export const TYPES = ['開発', 'デザイン', '営業', '事務', '確認', 'MTG', 'その他'];
export const ROLES: Record<string, string> = { pmo: 'PMO(管理者)', manager: 'マネージャー', member: 'メンバー' };
// 「毎日」= 平日毎日(月〜金)扱い。土日は休み運用
export const FREQ_LABELS: Record<string, string> = { daily: '毎日(平日)', weekly: '毎週', biweekly: '隔週', monthly: '毎月', quarter: '四半期', adhoc: '随時' };
export const FREQ_COLORS: Record<string, string> = {
  daily: 'text-red-400 bg-red-500/10 border-red-500/30',
  weekly: 'text-blue-400 bg-blue-500/10 border-blue-500/30',
  biweekly: 'text-purple-400 bg-purple-500/10 border-purple-500/30',
  monthly: 'text-orange-400 bg-orange-500/10 border-orange-500/30',
  quarter: 'text-cyan-400 bg-cyan-500/10 border-cyan-500/30',
  adhoc: 'text-gray-400 bg-gray-500/10 border-gray-500/30',
};

export const DEFAULT_MEMBERS: TaskMember[] = [
  { id: 'm1', name: '大串', pass: 'scale2024', role: 'pmo' },
  { id: 'm2', name: '細川', pass: 'scale2024', role: 'pmo' },
];

// =========== HELPERS ===========
// 担当者リストを取得(assignees 優先、無ければ assignee 単数を1件配列にする)
export function getAssigneeList(t: { assignee?: string; assignees?: string[] }): string[] {
  if (Array.isArray(t.assignees) && t.assignees.length > 0) return t.assignees.filter(Boolean);
  if (t.assignee && t.assignee.trim()) return [t.assignee.trim()];
  return [];
}
// 担当者を「、」区切りで表示
export function formatAssignees(t: { assignee?: string; assignees?: string[] }): string {
  return getAssigneeList(t).join('、');
}
// 自分担当か判定(部分一致 / 双方向)
export function isMineAssignee(t: { assignee?: string; assignees?: string[] }, myName: string): boolean {
  if (!myName) return false;
  return getAssigneeList(t).some(a => a === myName || a.includes(myName) || myName.includes(a));
}

export function uid(): string {
  return Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
}
export function today(): string {
  return new Date().toISOString().split('T')[0];
}
export function now(): string {
  return new Date().toISOString().slice(0, 16).replace('T', ' ');
}
export function fmtDate(d: string): string {
  if (!d) return '-';
  const dt = new Date(d + 'T00:00:00');
  return `${dt.getMonth() + 1}/${dt.getDate()}`;
}
export function daysDiff(d: string): number {
  if (!d) return 0;
  const t = new Date(today());
  const dd = new Date(d);
  return Math.floor((t.getTime() - dd.getTime()) / 86400000);
}
export const DAYS_OF_WEEK_JA = ['日', '月', '火', '水', '木', '金', '土'];

// 月曜起点の週始まりを返す
function mondayOf(d: Date): Date {
  const day = d.getDay();
  const diff = (day === 0 ? -6 : 1 - day); // 0=日曜→-6, 1=月曜→0, 2=火曜→-1, ...
  const m = new Date(d);
  m.setDate(d.getDate() + diff);
  m.setHours(0, 0, 0, 0);
  return m;
}

/**
 * 定例タスクが「現在の周期内」で完了済みかを判定。
 * - daily/weekdays  → 今日完了したか
 * - weekly          → 同じ週(月曜起点)内で完了したか
 * - biweekly        → 最終実施から14日未満
 * - monthly         → 同じ月内で完了したか
 * - quarter         → 同じ四半期内で完了したか
 * - adhoc           → 常に false(リセット不要、手動運用)
 */
export function isRoutineDoneInCurrentPeriod(bt: BottomTask, now: Date = new Date()): boolean {
  if (!bt.lastDone) return false;
  const lastDone = new Date(bt.lastDone + 'T00:00:00');
  if (isNaN(lastDone.getTime())) return false;

  const sameDay = (a: Date, b: Date) =>
    a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();

  switch (bt.frequency) {
    case 'daily':
    case 'weekdays':
      return sameDay(lastDone, now);
    case 'weekly': {
      const aMon = mondayOf(lastDone);
      const bMon = mondayOf(now);
      return sameDay(aMon, bMon);
    }
    case 'biweekly': {
      const dayDiff = Math.floor((now.getTime() - lastDone.getTime()) / 86400000);
      return dayDiff < 14;
    }
    case 'monthly':
      return lastDone.getFullYear() === now.getFullYear() && lastDone.getMonth() === now.getMonth();
    case 'quarter':
      return lastDone.getFullYear() === now.getFullYear() && Math.floor(lastDone.getMonth() / 3) === Math.floor(now.getMonth() / 3);
    case 'adhoc':
      return false; // 随時は常に未完了扱い(リセット制御しない)
    default:
      return sameDay(lastDone, now);
  }
}

// ───── 日本の祝日(2026-2028年)─────
// 給与振込・経理など「営業日」基準の判定で使用。年次でメンテナンス必須。
// 出典: 内閣府「国民の祝日に関する法律」/ 振替休日含む
const JAPAN_HOLIDAYS = new Set<string>([
  // 2026
  '2026-01-01', '2026-01-12', '2026-02-11', '2026-02-23', '2026-03-20',
  '2026-04-29', '2026-05-03', '2026-05-04', '2026-05-05', '2026-05-06',
  '2026-07-20', '2026-08-11', '2026-09-21', '2026-09-22', '2026-09-23',
  '2026-10-12', '2026-11-03', '2026-11-23',
  // 2027
  '2027-01-01', '2027-01-11', '2027-02-11', '2027-02-23', '2027-03-21', '2027-03-22',
  '2027-04-29', '2027-05-03', '2027-05-04', '2027-05-05',
  '2027-07-19', '2027-08-11', '2027-09-20', '2027-09-23', '2027-10-11', '2027-11-03', '2027-11-23',
  // 2028
  '2028-01-01', '2028-01-10', '2028-02-11', '2028-02-23', '2028-03-20',
  '2028-04-29', '2028-05-03', '2028-05-04', '2028-05-05',
  '2028-07-17', '2028-08-11', '2028-09-18', '2028-09-22', '2028-10-09', '2028-11-03', '2028-11-23',
]);

function ymd(d: Date): string {
  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}`;
}

// 営業日かどうか(土日 + 祝日を除く)
export function isBusinessDay(d: Date): boolean {
  const dow = d.getDay();
  if (dow === 0 || dow === 6) return false; // 土日
  if (JAPAN_HOLIDAYS.has(ymd(d))) return false; // 祝日
  return true;
}

// 月の最終営業日(土日祝を遡って最終の営業日を返す)
export function lastBusinessDayOfMonth(year: number, monthIndex: number): number {
  // monthIndex は 0-indexed
  const lastDay = new Date(year, monthIndex + 1, 0).getDate();
  for (let day = lastDay; day >= 1; day--) {
    const d = new Date(year, monthIndex, day);
    if (isBusinessDay(d)) return day;
  }
  return lastDay; // 全日休日のレアケースは月末を返す
}

// 定例タスクが今日該当するかを判定(曜日/日付ベース、後方互換で due も見る)
// dayOfMonth: 1-31 = 指定日 / -1 = 月末(暦上) / -2 = 月末(営業日: 土日祝除く)
export function isBottomTaskDueToday(bt: BottomTask): boolean {
  const now = new Date();
  const dow = now.getDay();
  const dom = now.getDate();
  if (bt.frequency === 'daily' || bt.frequency === 'weekdays') return dow >= 1 && dow <= 5; // 月〜金のみ(土日休み)
  if (bt.frequency === 'weekly' || bt.frequency === 'biweekly') {
    if (typeof bt.dayOfWeek === 'number') return bt.dayOfWeek === dow;
  }
  if (bt.frequency === 'monthly') {
    if (typeof bt.dayOfMonth === 'number') {
      if (bt.dayOfMonth === -1) {
        // 暦上の月末
        const lastDay = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate();
        return dom === lastDay;
      }
      if (bt.dayOfMonth === -2) {
        // 月末営業日(土日祝を除いた月最後の営業日)
        return dom === lastBusinessDayOfMonth(now.getFullYear(), now.getMonth());
      }
      return bt.dayOfMonth === dom;
    }
  }
  // 後方互換: 旧 due フィールド基準
  const td = today();
  return !!(bt.due === td || (bt.due && bt.due <= td));
}

// スケジュール表示(例: 毎週 火曜日 / 毎月 15日 / 毎月 月末 / 毎月 月末営業日)
export function formatBottomTaskSchedule(bt: BottomTask): string {
  if (bt.frequency === 'daily' || bt.frequency === 'weekdays') return '毎日(平日)';
  if (bt.frequency === 'weekly' || bt.frequency === 'biweekly') {
    const prefix = bt.frequency === 'biweekly' ? '隔週' : '毎週';
    if (typeof bt.dayOfWeek === 'number') return `${prefix} ${DAYS_OF_WEEK_JA[bt.dayOfWeek]}曜日`;
    return prefix;
  }
  if (bt.frequency === 'monthly') {
    if (typeof bt.dayOfMonth === 'number') {
      if (bt.dayOfMonth === -1) return '毎月 月末';
      if (bt.dayOfMonth === -2) return '毎月 月末営業日';
      return `毎月 ${bt.dayOfMonth}日`;
    }
    return '毎月';
  }
  if (bt.frequency === 'quarter') return '四半期';
  return FREQ_LABELS[bt.frequency] ?? bt.frequency;
}

export function memberColor(name: string): string {
  const colors = ['from-blue-500 to-blue-700', 'from-red-500 to-red-700', 'from-green-500 to-green-700', 'from-yellow-500 to-yellow-700', 'from-purple-500 to-purple-700', 'from-cyan-500 to-cyan-700', 'from-pink-500 to-pink-700', 'from-indigo-500 to-indigo-700'];
  let hash = 0;
  for (let i = 0; i < name.length; i++) hash = name.charCodeAt(i) + ((hash << 5) - hash);
  return colors[Math.abs(hash) % colors.length];
}

// =========== API LAYER ===========
async function fetchFromAPI(key: string): Promise<unknown> {
  try {
    const url = key === 'tasks' ? `${API_BASE}/tasks` : `${API_BASE}/data/${key}`;
    const res = await fetch(url);
    if (!res.ok) return null;
    return await res.json();
  } catch {
    return null;
  }
}

async function saveToAPI(key: string, value: unknown): Promise<void> {
  try {
    const url = key === 'tasks' ? `${API_BASE}/tasks` : `${API_BASE}/data/${key}`;
    await fetch(url, {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(value),
    });
  } catch {
    // silent fail
  }
}

// =========== CONTEXT ===========
interface TasksContextType {
  tasks: Task[];
  projects: Project[];
  bottomTasks: BottomTask[];
  members: TaskMember[];
  auditLog: AuditEntry[];
  isLoading: boolean;
  currentUser: string;
  saveTasks: (tasks: Task[]) => void;
  saveProjects: (projects: Project[]) => void;
  saveBottomTasks: (bt: BottomTask[]) => void;
  saveMembers: (members: TaskMember[]) => void;
  addAudit: (action: string, detail: string, taskId?: string) => void;
  scanSlack: () => Promise<SlackScanResult | null>;
  reload: () => Promise<void>;
  undo: () => { ok: boolean; label?: string };
  canUndo: boolean;
}

const TasksContext = createContext<TasksContextType | null>(null);

export function TasksProvider({ children, currentUser }: { children: ReactNode; currentUser: string }) {
  const [tasks, setTasks] = useState<Task[]>([]);
  const [projects, setProjects] = useState<Project[]>([]);
  const [bottomTasks, setBottomTasks] = useState<BottomTask[]>([]);
  const [members, setMembers] = useState<TaskMember[]>(DEFAULT_MEMBERS);
  const [auditLog, setAuditLog] = useState<AuditEntry[]>([]);
  const [isLoading, setIsLoading] = useState(true);

  const loadAll = useCallback(async () => {
    const [t, p, bt, m, al] = await Promise.all([
      fetchFromAPI('tasks'),
      fetchFromAPI('projects'),
      fetchFromAPI('bottom_tasks'),
      fetchFromAPI('members'),
      fetchFromAPI('audit_log'),
    ]);
    setTasks((t as Task[]) || []);
    setProjects((p as Project[]) || []);
    setBottomTasks((bt as BottomTask[]) || []);
    setMembers((m as TaskMember[]) || DEFAULT_MEMBERS);
    setAuditLog((al as AuditEntry[]) || []);
    if (!m || !(m as TaskMember[]).length) {
      saveToAPI('members', DEFAULT_MEMBERS);
    }
    setIsLoading(false);
  }, []);

  useEffect(() => { loadAll(); }, [loadAll]);

  // ブラウザがアクティブに戻ったとき / 60秒ごとに再取得(Slack経由の更新を拾う)
  useEffect(() => {
    const onFocus = () => { loadAll(); };
    const onVis = () => { if (document.visibilityState === 'visible') loadAll(); };
    window.addEventListener('focus', onFocus);
    document.addEventListener('visibilitychange', onVis);
    const interval = setInterval(() => { loadAll(); }, 60000);
    return () => {
      window.removeEventListener('focus', onFocus);
      document.removeEventListener('visibilitychange', onVis);
      clearInterval(interval);
    };
  }, [loadAll]);

  // Undo 履歴(直近20件)
  const historyRef = useRef<HistoryEntry[]>([]);
  const [canUndo, setCanUndo] = useState(false);
  const pushHistory = useCallback((type: HistoryEntryType, before: HistoryEntry['before'], label?: string) => {
    historyRef.current = [...historyRef.current, { type, before, time: Date.now(), label }].slice(-20);
    setCanUndo(true);
  }, []);

  const saveTasks = useCallback((t: Task[]) => {
    pushHistory('tasks', tasks, 'タスク');
    setTasks(t);
    saveToAPI('tasks', t);
  }, [tasks, pushHistory]);

  const saveProjects = useCallback((p: Project[]) => {
    pushHistory('projects', projects, 'PJT');
    setProjects(p);
    saveToAPI('projects', p);
  }, [projects, pushHistory]);

  const saveBottomTasks = useCallback((bt: BottomTask[]) => {
    pushHistory('bottom_tasks', bottomTasks, '定例タスク');
    setBottomTasks(bt);
    saveToAPI('bottom_tasks', bt);
  }, [bottomTasks, pushHistory]);

  const saveMembers = useCallback((m: TaskMember[]) => {
    pushHistory('members', members, 'メンバー');
    setMembers(m);
    saveToAPI('members', m);
  }, [members, pushHistory]);

  const undo = useCallback((): { ok: boolean; label?: string } => {
    const stack = historyRef.current;
    if (stack.length === 0) return { ok: false };
    const last = stack[stack.length - 1];
    historyRef.current = stack.slice(0, -1);
    setCanUndo(historyRef.current.length > 0);
    switch (last.type) {
      case 'tasks':
        setTasks(last.before as Task[]);
        saveToAPI('tasks', last.before);
        break;
      case 'projects':
        setProjects(last.before as Project[]);
        saveToAPI('projects', last.before);
        break;
      case 'bottom_tasks':
        setBottomTasks(last.before as BottomTask[]);
        saveToAPI('bottom_tasks', last.before);
        break;
      case 'members':
        setMembers(last.before as TaskMember[]);
        saveToAPI('members', last.before);
        break;
    }
    return { ok: true, label: last.label };
  }, []);

  // Cmd+Z / Ctrl+Z でUndo(input/textarea内はブラウザ標準のundoに任せる)
  useEffect(() => {
    const onKey = (e: KeyboardEvent) => {
      if (!(e.metaKey || e.ctrlKey)) return;
      if (e.key !== 'z' && e.key !== 'Z') return;
      if (e.shiftKey) return; // Redoは未対応
      const active = document.activeElement;
      const tag = active?.tagName?.toLowerCase();
      if (tag === 'input' || tag === 'textarea' || (active as HTMLElement)?.isContentEditable) return;
      e.preventDefault();
      const result = undo();
      if (result.ok) {
        showUndoToast(result.label);
      }
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [undo]);

  const addAudit = useCallback((action: string, detail: string, taskId?: string) => {
    setAuditLog(prev => {
      const entry: AuditEntry = { id: uid(), user: currentUser, action, detail, taskId: taskId || '', time: now() };
      const next = [entry, ...prev];
      if (next.length > 5000) next.length = 5000;
      saveToAPI('audit_log', next);
      return next;
    });
  }, [currentUser]);

  const scanSlack = useCallback(async (): Promise<SlackScanResult | null> => {
    try {
      const res = await fetch(`${API_BASE}/scan`);
      const data = await res.json() as SlackScanResult;
      if (data.addedCount && data.addedCount > 0) {
        const fresh = await fetchFromAPI('tasks') as Task[];
        if (fresh) setTasks(fresh);
      }
      return data;
    } catch {
      return null;
    }
  }, []);

  const reload = useCallback(async () => {
    await loadAll();
  }, [loadAll]);

  return (
    <TasksContext.Provider value={{
      tasks, projects, bottomTasks, members, auditLog, isLoading, currentUser,
      saveTasks, saveProjects, saveBottomTasks, saveMembers, addAudit, scanSlack, reload,
      undo, canUndo,
    }}>
      {children}
    </TasksContext.Provider>
  );
}

// ── 画面上部にUndo通知トーストを表示 ──
function showUndoToast(label?: string) {
  if (typeof document === 'undefined') return;
  const existing = document.getElementById('scale-undo-toast');
  if (existing) existing.remove();
  const toast = document.createElement('div');
  toast.id = 'scale-undo-toast';
  toast.textContent = label ? `↶ ${label}を元に戻しました` : '↶ 元に戻しました';
  toast.style.cssText = `
    position: fixed; top: 20px; left: 50%; transform: translateX(-50%);
    background: rgba(100,133,184,0.95); color: white; padding: 10px 18px;
    border-radius: 8px; font-size: 13px; font-weight: 500; z-index: 9999;
    box-shadow: 0 4px 20px rgba(0,0,0,0.4); animation: scaleFadeIn .15s ease-out;
  `;
  document.body.appendChild(toast);
  setTimeout(() => toast.remove(), 1800);
}

export function useTasks(): TasksContextType {
  const ctx = useContext(TasksContext);
  if (!ctx) throw new Error('useTasks must be used within TasksProvider');
  return ctx;
}

前提条件
Tailwind CSS v4TypeScript 5
USE CASES
  • タスク管理
  • プロジェクト管理
  • ToDo アプリ

タスク API レイヤー

:LiTarget: 用途

タスク CRUD + KV キャッシュ + リアルタイム同期の API レイヤー。

:LiSparkle: 特徴

  • CRUD操作
  • KVキャッシュ
  • リアルタイム同期
  • 楽観的更新

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

:LiInfo: lib/tasks-api.tsx の中身そのもの。コピペ即可。

'use client';

import { createContext, useContext, useState, useEffect, useCallback, useRef, type ReactNode } from 'react';

// =========== CONFIG ===========
const API_BASE = 'https://scale-task-cron.y-ogushi.workers.dev';

// =========== TYPES ===========
export interface DateChange {
  from: string;
  to: string;
  user: string;
  time: string;
  reason: string;
}

export interface StatusChange {
  from: string;
  to: string;
  user: string;
  time: string;
  reason: string;
}

export interface TaskComment {
  user: string;
  text: string;
  time: string;
}

export interface Task {
  id: string;
  name: string;
  assignee: string;
  // 複数担当対応(assignees)。後方互換のため単数 assignee も維持し、両方を join('、') 等で統合可能。
  assignees?: string[];
  requester: string;
  due: string;
  originalDue: string;
  priority: number;
  status: string;
  type: string;
  project: string;
  memo: string;
  link: string;
  // 詳細説明(何のタスクか・完了条件・注意点)。Slack連携時にClaudeで自動生成可
  description?: string;
  // 関連リンク(複数)。link は単数の後方互換用、links は配列
  links?: { label?: string; url: string }[];
  // 想定所要時間(分)
  estimateMinutes?: number;
  // 由来(どこから追加されたか): 'manual' | 'morning' | 'active' | 'end' | 'slack-bot' | 'routine'
  origin?: string;
  todayFlag: boolean;
  dateChanges: DateChange[];
  statusChanges: StatusChange[];
  editHistory: { field: string; from: string; to: string; user: string; time: string }[];
  comments: TaskComment[];
  createdBy: string;
  createdAt: string;
  completedDate: string | null;
  confirmedBy: string | null;
  fmt?: string;
}

export interface Project {
  id: string;
  name: string;
  owner: string;
  status: string;
  startDate: string;
  endDate: string;
  memo: string;
  goal: string;
}

export interface BottomTask {
  id: string;
  name: string;
  assignee: string;
  frequency: string;
  due: string; // 後方互換。現在は dayOfWeek / dayOfMonth を優先
  dayOfWeek?: number; // 0=日 〜 6=土(weekly / biweekly 用)
  dayOfMonth?: number; // 1-31 または -1=月末(monthly 用)
  project?: string; // 紐付くPJTのID
  memo: string;
  link?: string; // 関連URL(LinkedInアカウント / 使用ツール等)。作業中画面で🔗ボタン表示
  estimateMinutes?: number; // 目安作業時間(分)。朝日報/作業開始の合計表示で使用
  lastDone: string | null;
  createdAt: string;
}

export interface TaskMember {
  id: string;
  name: string;
  pass: string;
  role: string;
  icon?: string; // 絵文字アイコン(例: '🐻')。未設定時は名前の頭文字
  iconBg?: string; // アイコン背景色(HEX)。未設定時は名前ベースのグラデ
}

export const MEMBER_EMOJI_PRESETS = [
  '🐻', '🐼', '🦊', '🐱', '🐶', '🐰', '🐯', '🐸', '🐷', '🐵',
  '🦄', '🦁', '🐨', '🐹', '🐺', '🐧', '🦉', '🐙', '🦝', '🐠',
  '🦋', '🐝', '🐢', '🦖', '🐳', '🦈', '🐉', '🦚',
  '🍎', '🍊', '🍋', '🍓', '🥑', '🍕', '🍔', '🍩', '🍪', '🎂',
  '⭐️', '✨', '🔥', '❄️', '💎', '🌙', '☀️', '⚡️', '🌈', '🎯',
  '🚀', '🤖', '👻', '🎨', '🎪', '🏆', '💡', '🎮', '📚', '🎸',
];

export const MEMBER_BG_PRESETS = [
  '#fde2d3', '#fdd4e8', '#e8d9f7', '#d1ebf5', '#d4f0dd',
  '#f7ecc9', '#edd5f2', '#d4def0', '#f0dbc9', '#e0e0e5',
  '#ffc8a8', '#ffcadb', '#d4c0ea', '#b5ddee', '#bfe6ca',
  '#f3ddaa', '#e2b9ee', '#b9ccea', '#e8bfa0', '#c9c9d0',
];

export interface AuditEntry {
  id: string;
  user: string;
  action: string;
  detail: string;
  taskId: string;
  time: string;
}

export interface SlackScanResult {
  tasks: { name: string; assignee: string; due: string; priority: number; channel: string; summary: string; source_user: string }[];
  channel: string;
  messages: number;
  scannedAt: string;
  addedCount?: number;
  error?: string;
}

type HistoryEntryType = 'tasks' | 'projects' | 'bottom_tasks' | 'members';
interface HistoryEntry {
  type: HistoryEntryType;
  before: Task[] | Project[] | BottomTask[] | TaskMember[];
  time: number;
  label?: string;
}

// =========== CONSTANTS ===========
export const STATUSES = ['未着手', '進行中', '確認待ち', '完了', '保留', '中止'];
export const STATUS_COLORS: Record<string, string> = {
  '未着手': 'text-gray-400 bg-gray-500/10 border-gray-500/30',
  '進行中': 'text-blue-400 bg-blue-500/10 border-blue-500/30',
  '確認待ち': 'text-orange-400 bg-orange-500/10 border-orange-500/30',
  '完了': 'text-green-400 bg-green-500/10 border-green-500/30',
  '保留': 'text-purple-400 bg-purple-500/10 border-purple-500/30',
  '中止': 'text-red-400 bg-red-500/10 border-red-500/30',
};
export const TYPES = ['開発', 'デザイン', '営業', '事務', '確認', 'MTG', 'その他'];
export const ROLES: Record<string, string> = { pmo: 'PMO(管理者)', manager: 'マネージャー', member: 'メンバー' };
// 「毎日」= 平日毎日(月〜金)扱い。土日は休み運用
export const FREQ_LABELS: Record<string, string> = { daily: '毎日(平日)', weekly: '毎週', biweekly: '隔週', monthly: '毎月', quarter: '四半期', adhoc: '随時' };
export const FREQ_COLORS: Record<string, string> = {
  daily: 'text-red-400 bg-red-500/10 border-red-500/30',
  weekly: 'text-blue-400 bg-blue-500/10 border-blue-500/30',
  biweekly: 'text-purple-400 bg-purple-500/10 border-purple-500/30',
  monthly: 'text-orange-400 bg-orange-500/10 border-orange-500/30',
  quarter: 'text-cyan-400 bg-cyan-500/10 border-cyan-500/30',
  adhoc: 'text-gray-400 bg-gray-500/10 border-gray-500/30',
};

export const DEFAULT_MEMBERS: TaskMember[] = [
  { id: 'm1', name: '大串', pass: 'scale2024', role: 'pmo' },
  { id: 'm2', name: '細川', pass: 'scale2024', role: 'pmo' },
];

// =========== HELPERS ===========
// 担当者リストを取得(assignees 優先、無ければ assignee 単数を1件配列にする)
export function getAssigneeList(t: { assignee?: string; assignees?: string[] }): string[] {
  if (Array.isArray(t.assignees) && t.assignees.length > 0) return t.assignees.filter(Boolean);
  if (t.assignee && t.assignee.trim()) return [t.assignee.trim()];
  return [];
}
// 担当者を「、」区切りで表示
export function formatAssignees(t: { assignee?: string; assignees?: string[] }): string {
  return getAssigneeList(t).join('、');
}
// 自分担当か判定(部分一致 / 双方向)
export function isMineAssignee(t: { assignee?: string; assignees?: string[] }, myName: string): boolean {
  if (!myName) return false;
  return getAssigneeList(t).some(a => a === myName || a.includes(myName) || myName.includes(a));
}

export function uid(): string {
  return Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
}
export function today(): string {
  return new Date().toISOString().split('T')[0];
}
export function now(): string {
  return new Date().toISOString().slice(0, 16).replace('T', ' ');
}
export function fmtDate(d: string): string {
  if (!d) return '-';
  const dt = new Date(d + 'T00:00:00');
  return `${dt.getMonth() + 1}/${dt.getDate()}`;
}
export function daysDiff(d: string): number {
  if (!d) return 0;
  const t = new Date(today());
  const dd = new Date(d);
  return Math.floor((t.getTime() - dd.getTime()) / 86400000);
}
export const DAYS_OF_WEEK_JA = ['日', '月', '火', '水', '木', '金', '土'];

// 月曜起点の週始まりを返す
function mondayOf(d: Date): Date {
  const day = d.getDay();
  const diff = (day === 0 ? -6 : 1 - day); // 0=日曜→-6, 1=月曜→0, 2=火曜→-1, ...
  const m = new Date(d);
  m.setDate(d.getDate() + diff);
  m.setHours(0, 0, 0, 0);
  return m;
}

/**
 * 定例タスクが「現在の周期内」で完了済みかを判定。
 * - daily/weekdays  → 今日完了したか
 * - weekly          → 同じ週(月曜起点)内で完了したか
 * - biweekly        → 最終実施から14日未満
 * - monthly         → 同じ月内で完了したか
 * - quarter         → 同じ四半期内で完了したか
 * - adhoc           → 常に false(リセット不要、手動運用)
 */
export function isRoutineDoneInCurrentPeriod(bt: BottomTask, now: Date = new Date()): boolean {
  if (!bt.lastDone) return false;
  const lastDone = new Date(bt.lastDone + 'T00:00:00');
  if (isNaN(lastDone.getTime())) return false;

  const sameDay = (a: Date, b: Date) =>
    a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();

  switch (bt.frequency) {
    case 'daily':
    case 'weekdays':
      return sameDay(lastDone, now);
    case 'weekly': {
      const aMon = mondayOf(lastDone);
      const bMon = mondayOf(now);
      return sameDay(aMon, bMon);
    }
    case 'biweekly': {
      const dayDiff = Math.floor((now.getTime() - lastDone.getTime()) / 86400000);
      return dayDiff < 14;
    }
    case 'monthly':
      return lastDone.getFullYear() === now.getFullYear() && lastDone.getMonth() === now.getMonth();
    case 'quarter':
      return lastDone.getFullYear() === now.getFullYear() && Math.floor(lastDone.getMonth() / 3) === Math.floor(now.getMonth() / 3);
    case 'adhoc':
      return false; // 随時は常に未完了扱い(リセット制御しない)
    default:
      return sameDay(lastDone, now);
  }
}

// ───── 日本の祝日(2026-2028年)─────
// 給与振込・経理など「営業日」基準の判定で使用。年次でメンテナンス必須。
// 出典: 内閣府「国民の祝日に関する法律」/ 振替休日含む
const JAPAN_HOLIDAYS = new Set<string>([
  // 2026
  '2026-01-01', '2026-01-12', '2026-02-11', '2026-02-23', '2026-03-20',
  '2026-04-29', '2026-05-03', '2026-05-04', '2026-05-05', '2026-05-06',
  '2026-07-20', '2026-08-11', '2026-09-21', '2026-09-22', '2026-09-23',
  '2026-10-12', '2026-11-03', '2026-11-23',
  // 2027
  '2027-01-01', '2027-01-11', '2027-02-11', '2027-02-23', '2027-03-21', '2027-03-22',
  '2027-04-29', '2027-05-03', '2027-05-04', '2027-05-05',
  '2027-07-19', '2027-08-11', '2027-09-20', '2027-09-23', '2027-10-11', '2027-11-03', '2027-11-23',
  // 2028
  '2028-01-01', '2028-01-10', '2028-02-11', '2028-02-23', '2028-03-20',
  '2028-04-29', '2028-05-03', '2028-05-04', '2028-05-05',
  '2028-07-17', '2028-08-11', '2028-09-18', '2028-09-22', '2028-10-09', '2028-11-03', '2028-11-23',
]);

function ymd(d: Date): string {
  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}`;
}

// 営業日かどうか(土日 + 祝日を除く)
export function isBusinessDay(d: Date): boolean {
  const dow = d.getDay();
  if (dow === 0 || dow === 6) return false; // 土日
  if (JAPAN_HOLIDAYS.has(ymd(d))) return false; // 祝日
  return true;
}

// 月の最終営業日(土日祝を遡って最終の営業日を返す)
export function lastBusinessDayOfMonth(year: number, monthIndex: number): number {
  // monthIndex は 0-indexed
  const lastDay = new Date(year, monthIndex + 1, 0).getDate();
  for (let day = lastDay; day >= 1; day--) {
    const d = new Date(year, monthIndex, day);
    if (isBusinessDay(d)) return day;
  }
  return lastDay; // 全日休日のレアケースは月末を返す
}

// 定例タスクが今日該当するかを判定(曜日/日付ベース、後方互換で due も見る)
// dayOfMonth: 1-31 = 指定日 / -1 = 月末(暦上) / -2 = 月末(営業日: 土日祝除く)
export function isBottomTaskDueToday(bt: BottomTask): boolean {
  const now = new Date();
  const dow = now.getDay();
  const dom = now.getDate();
  if (bt.frequency === 'daily' || bt.frequency === 'weekdays') return dow >= 1 && dow <= 5; // 月〜金のみ(土日休み)
  if (bt.frequency === 'weekly' || bt.frequency === 'biweekly') {
    if (typeof bt.dayOfWeek === 'number') return bt.dayOfWeek === dow;
  }
  if (bt.frequency === 'monthly') {
    if (typeof bt.dayOfMonth === 'number') {
      if (bt.dayOfMonth === -1) {
        // 暦上の月末
        const lastDay = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate();
        return dom === lastDay;
      }
      if (bt.dayOfMonth === -2) {
        // 月末営業日(土日祝を除いた月最後の営業日)
        return dom === lastBusinessDayOfMonth(now.getFullYear(), now.getMonth());
      }
      return bt.dayOfMonth === dom;
    }
  }
  // 後方互換: 旧 due フィールド基準
  const td = today();
  return !!(bt.due === td || (bt.due && bt.due <= td));
}

// スケジュール表示(例: 毎週 火曜日 / 毎月 15日 / 毎月 月末 / 毎月 月末営業日)
export function formatBottomTaskSchedule(bt: BottomTask): string {
  if (bt.frequency === 'daily' || bt.frequency === 'weekdays') return '毎日(平日)';
  if (bt.frequency === 'weekly' || bt.frequency === 'biweekly') {
    const prefix = bt.frequency === 'biweekly' ? '隔週' : '毎週';
    if (typeof bt.dayOfWeek === 'number') return `${prefix} ${DAYS_OF_WEEK_JA[bt.dayOfWeek]}曜日`;
    return prefix;
  }
  if (bt.frequency === 'monthly') {
    if (typeof bt.dayOfMonth === 'number') {
      if (bt.dayOfMonth === -1) return '毎月 月末';
      if (bt.dayOfMonth === -2) return '毎月 月末営業日';
      return `毎月 ${bt.dayOfMonth}日`;
    }
    return '毎月';
  }
  if (bt.frequency === 'quarter') return '四半期';
  return FREQ_LABELS[bt.frequency] ?? bt.frequency;
}

export function memberColor(name: string): string {
  const colors = ['from-blue-500 to-blue-700', 'from-red-500 to-red-700', 'from-green-500 to-green-700', 'from-yellow-500 to-yellow-700', 'from-purple-500 to-purple-700', 'from-cyan-500 to-cyan-700', 'from-pink-500 to-pink-700', 'from-indigo-500 to-indigo-700'];
  let hash = 0;
  for (let i = 0; i < name.length; i++) hash = name.charCodeAt(i) + ((hash << 5) - hash);
  return colors[Math.abs(hash) % colors.length];
}

// =========== API LAYER ===========
async function fetchFromAPI(key: string): Promise<unknown> {
  try {
    const url = key === 'tasks' ? `${API_BASE}/tasks` : `${API_BASE}/data/${key}`;
    const res = await fetch(url);
    if (!res.ok) return null;
    return await res.json();
  } catch {
    return null;
  }
}

async function saveToAPI(key: string, value: unknown): Promise<void> {
  try {
    const url = key === 'tasks' ? `${API_BASE}/tasks` : `${API_BASE}/data/${key}`;
    await fetch(url, {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(value),
    });
  } catch {
    // silent fail
  }
}

// =========== CONTEXT ===========
interface TasksContextType {
  tasks: Task[];
  projects: Project[];
  bottomTasks: BottomTask[];
  members: TaskMember[];
  auditLog: AuditEntry[];
  isLoading: boolean;
  currentUser: string;
  saveTasks: (tasks: Task[]) => void;
  saveProjects: (projects: Project[]) => void;
  saveBottomTasks: (bt: BottomTask[]) => void;
  saveMembers: (members: TaskMember[]) => void;
  addAudit: (action: string, detail: string, taskId?: string) => void;
  scanSlack: () => Promise<SlackScanResult | null>;
  reload: () => Promise<void>;
  undo: () => { ok: boolean; label?: string };
  canUndo: boolean;
}

const TasksContext = createContext<TasksContextType | null>(null);

export function TasksProvider({ children, currentUser }: { children: ReactNode; currentUser: string }) {
  const [tasks, setTasks] = useState<Task[]>([]);
  const [projects, setProjects] = useState<Project[]>([]);
  const [bottomTasks, setBottomTasks] = useState<BottomTask[]>([]);
  const [members, setMembers] = useState<TaskMember[]>(DEFAULT_MEMBERS);
  const [auditLog, setAuditLog] = useState<AuditEntry[]>([]);
  const [isLoading, setIsLoading] = useState(true);

  const loadAll = useCallback(async () => {
    const [t, p, bt, m, al] = await Promise.all([
      fetchFromAPI('tasks'),
      fetchFromAPI('projects'),
      fetchFromAPI('bottom_tasks'),
      fetchFromAPI('members'),
      fetchFromAPI('audit_log'),
    ]);
    setTasks((t as Task[]) || []);
    setProjects((p as Project[]) || []);
    setBottomTasks((bt as BottomTask[]) || []);
    setMembers((m as TaskMember[]) || DEFAULT_MEMBERS);
    setAuditLog((al as AuditEntry[]) || []);
    if (!m || !(m as TaskMember[]).length) {
      saveToAPI('members', DEFAULT_MEMBERS);
    }
    setIsLoading(false);
  }, []);

  useEffect(() => { loadAll(); }, [loadAll]);

  // ブラウザがアクティブに戻ったとき / 60秒ごとに再取得(Slack経由の更新を拾う)
  useEffect(() => {
    const onFocus = () => { loadAll(); };
    const onVis = () => { if (document.visibilityState === 'visible') loadAll(); };
    window.addEventListener('focus', onFocus);
    document.addEventListener('visibilitychange', onVis);
    const interval = setInterval(() => { loadAll(); }, 60000);
    return () => {
      window.removeEventListener('focus', onFocus);
      document.removeEventListener('visibilitychange', onVis);
      clearInterval(interval);
    };
  }, [loadAll]);

  // Undo 履歴(直近20件)
  const historyRef = useRef<HistoryEntry[]>([]);
  const [canUndo, setCanUndo] = useState(false);
  const pushHistory = useCallback((type: HistoryEntryType, before: HistoryEntry['before'], label?: string) => {
    historyRef.current = [...historyRef.current, { type, before, time: Date.now(), label }].slice(-20);
    setCanUndo(true);
  }, []);

  const saveTasks = useCallback((t: Task[]) => {
    pushHistory('tasks', tasks, 'タスク');
    setTasks(t);
    saveToAPI('tasks', t);
  }, [tasks, pushHistory]);

  const saveProjects = useCallback((p: Project[]) => {
    pushHistory('projects', projects, 'PJT');
    setProjects(p);
    saveToAPI('projects', p);
  }, [projects, pushHistory]);

  const saveBottomTasks = useCallback((bt: BottomTask[]) => {
    pushHistory('bottom_tasks', bottomTasks, '定例タスク');
    setBottomTasks(bt);
    saveToAPI('bottom_tasks', bt);
  }, [bottomTasks, pushHistory]);

  const saveMembers = useCallback((m: TaskMember[]) => {
    pushHistory('members', members, 'メンバー');
    setMembers(m);
    saveToAPI('members', m);
  }, [members, pushHistory]);

  const undo = useCallback((): { ok: boolean; label?: string } => {
    const stack = historyRef.current;
    if (stack.length === 0) return { ok: false };
    const last = stack[stack.length - 1];
    historyRef.current = stack.slice(0, -1);
    setCanUndo(historyRef.current.length > 0);
    switch (last.type) {
      case 'tasks':
        setTasks(last.before as Task[]);
        saveToAPI('tasks', last.before);
        break;
      case 'projects':
        setProjects(last.before as Project[]);
        saveToAPI('projects', last.before);
        break;
      case 'bottom_tasks':
        setBottomTasks(last.before as BottomTask[]);
        saveToAPI('bottom_tasks', last.before);
        break;
      case 'members':
        setMembers(last.before as TaskMember[]);
        saveToAPI('members', last.before);
        break;
    }
    return { ok: true, label: last.label };
  }, []);

  // Cmd+Z / Ctrl+Z でUndo(input/textarea内はブラウザ標準のundoに任せる)
  useEffect(() => {
    const onKey = (e: KeyboardEvent) => {
      if (!(e.metaKey || e.ctrlKey)) return;
      if (e.key !== 'z' && e.key !== 'Z') return;
      if (e.shiftKey) return; // Redoは未対応
      const active = document.activeElement;
      const tag = active?.tagName?.toLowerCase();
      if (tag === 'input' || tag === 'textarea' || (active as HTMLElement)?.isContentEditable) return;
      e.preventDefault();
      const result = undo();
      if (result.ok) {
        showUndoToast(result.label);
      }
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [undo]);

  const addAudit = useCallback((action: string, detail: string, taskId?: string) => {
    setAuditLog(prev => {
      const entry: AuditEntry = { id: uid(), user: currentUser, action, detail, taskId: taskId || '', time: now() };
      const next = [entry, ...prev];
      if (next.length > 5000) next.length = 5000;
      saveToAPI('audit_log', next);
      return next;
    });
  }, [currentUser]);

  const scanSlack = useCallback(async (): Promise<SlackScanResult | null> => {
    try {
      const res = await fetch(`${API_BASE}/scan`);
      const data = await res.json() as SlackScanResult;
      if (data.addedCount && data.addedCount > 0) {
        const fresh = await fetchFromAPI('tasks') as Task[];
        if (fresh) setTasks(fresh);
      }
      return data;
    } catch {
      return null;
    }
  }, []);

  const reload = useCallback(async () => {
    await loadAll();
  }, [loadAll]);

  return (
    <TasksContext.Provider value={{
      tasks, projects, bottomTasks, members, auditLog, isLoading, currentUser,
      saveTasks, saveProjects, saveBottomTasks, saveMembers, addAudit, scanSlack, reload,
      undo, canUndo,
    }}>
      {children}
    </TasksContext.Provider>
  );
}

// ── 画面上部にUndo通知トーストを表示 ──
function showUndoToast(label?: string) {
  if (typeof document === 'undefined') return;
  const existing = document.getElementById('scale-undo-toast');
  if (existing) existing.remove();
  const toast = document.createElement('div');
  toast.id = 'scale-undo-toast';
  toast.textContent = label ? `↶ ${label}を元に戻しました` : '↶ 元に戻しました';
  toast.style.cssText = `
    position: fixed; top: 20px; left: 50%; transform: translateX(-50%);
    background: rgba(100,133,184,0.95); color: white; padding: 10px 18px;
    border-radius: 8px; font-size: 13px; font-weight: 500; z-index: 9999;
    box-shadow: 0 4px 20px rgba(0,0,0,0.4); animation: scaleFadeIn .15s ease-out;
  `;
  document.body.appendChild(toast);
  setTimeout(() => toast.remove(), 1800);
}

export function useTasks(): TasksContextType {
  const ctx = useContext(TasksContext);
  if (!ctx) throw new Error('useTasks must be used within TasksProvider');
  return ctx;
}

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

/Users/oogushiyuuki/Library/CloudStorage/GoogleDrive-y-ogushi@scale-group.co.jp/マイドライブ/AI/scale-base/lib/tasks-api.tsx

:LiHandPointer: 使い方

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

:LiAlertCircle: 注意事項

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