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

採用データベース

CATEGORY開発パターン TYPETypeScript Library EFFORT120〜360分 DIFFICULTY
PRIMARY CODE
ts · lib/recruit-database.ts
const isSupabaseConfigured = false; const supabase: any = null
import { mockCandidates, mockInterviews, mockEmailTemplates, mockDashboardStats } from './recruit-mock-data'
import type { Candidate, PipelineStageId, SourcePlatform, Interview, EmailTemplate, DashboardStats } from './recruit-types'

// ============================================
// Candidates
// ============================================

export async function getCandidates(filters?: {
  stage?: PipelineStageId | 'all'
  platform?: SourcePlatform | 'all'
  search?: string
  sortBy?: 'applied_at' | 'ai_screening_score'
}): Promise<Candidate[]> {
  if (!isSupabaseConfigured || !supabase) {
    let result = [...mockCandidates]
    if (filters?.stage && filters.stage !== 'all') {
      result = result.filter((c: any) => c.pipeline_stage === filters.stage)
    }
    if (filters?.platform && filters.platform !== 'all') {
      result = result.filter((c: any) => c.source_platform === filters.platform)
    }
    if (filters?.search) {
      const q = filters.search.toLowerCase()
      result = result.filter((c: any) =>
        c.name.toLowerCase().includes(q) ||
        c.email.toLowerCase().includes(q) ||
        c.phone.includes(q)
      )
    }
    result.sort((a, b) => {
      if (filters?.sortBy === 'ai_screening_score') {
        return (b.ai_screening_score ?? 0) - (a.ai_screening_score ?? 0)
      }
      return new Date(b.applied_at).getTime() - new Date(a.applied_at).getTime()
    })
    return result
  }

  let query = supabase.from('candidates').select('*, candidate_tags(*)')

  if (filters?.stage && filters.stage !== 'all') {
    query = query.eq('pipeline_stage', filters.stage)
  }
  if (filters?.platform && filters.platform !== 'all') {
    query = query.eq('source_platform', filters.platform)
  }
  if (filters?.search) {
    query = query.or(`name.ilike.%${filters.search}%,email.ilike.%${filters.search}%,phone.ilike.%${filters.search}%`)
  }
  if (filters?.sortBy === 'ai_screening_score') {
    query = query.order('ai_screening_score', { ascending: false, nullsFirst: false })
  } else {
    query = query.order('applied_at', { ascending: false })
  }

  const { data, error } = await query
  if (error) throw error
  return (data || []).map((d: any) => ({ ...d, tags: d.candidate_tags }))
}

export async function getCandidate(id: string): Promise<Candidate | null> {
  if (!isSupabaseConfigured || !supabase) {
    return mockCandidates.find(c => c.id === id) || null
  }

  const { data, error } = await supabase
    .from('candidates')
    .select('*, candidate_tags(*), candidate_notes(*)')
    .eq('id', id)
    .single()

  if (error) return null
  return { ...data, tags: data.candidate_tags, notes: data.candidate_notes }
}

export async function createCandidate(candidate: Partial<Candidate>): Promise<Candidate> {
  if (!isSupabaseConfigured || !supabase) {
    const newCandidate: Candidate = {
      id: crypto.randomUUID(),
      name: candidate.name || '',
      email: candidate.email || '',
      phone: candidate.phone || '',
      age: candidate.age || null,
      current_occupation: candidate.current_occupation || null,
      sales_experience_years: candidate.sales_experience_years || null,
      teleapo_experience: candidate.teleapo_experience || null,
      monthly_hours: candidate.monthly_hours || null,
      preferred_compensation: candidate.preferred_compensation || null,
      self_pr: candidate.self_pr || null,
      resume_url: candidate.resume_url || null,
      source_platform: candidate.source_platform || 'direct',
      source_url: candidate.source_url || null,
      utm_source: candidate.utm_source || null,
      utm_medium: candidate.utm_medium || null,
      ai_screening_score: null,
      ai_screening_reason: null,
      ai_matching_score: null,
      pipeline_stage: 'new',
      assigned_to: null,
      applied_at: new Date().toISOString(),
      created_at: new Date().toISOString(),
      updated_at: new Date().toISOString(),
    }
    mockCandidates.unshift(newCandidate)
    return newCandidate
  }

  const { data, error } = await supabase
    .from('candidates')
    .insert({
      ...candidate,
      pipeline_stage: 'new',
    })
    .select()
    .single()

  if (error) throw error
  return data
}

export async function updateCandidateStage(
  candidateId: string,
  newStage: PipelineStageId,
  reason?: string
): Promise<void> {
  if (!isSupabaseConfigured || !supabase) {
    const idx = mockCandidates.findIndex(c => c.id === candidateId)
    if (idx >= 0) {
      mockCandidates[idx].pipeline_stage = newStage
      mockCandidates[idx].updated_at = new Date().toISOString()
    }
    return
  }

  const { data: candidate } = await supabase
    .from('candidates')
    .select('pipeline_stage')
    .eq('id', candidateId)
    .single()

  const fromStage = candidate?.pipeline_stage || 'new'

  await supabase
    .from('candidates')
    .update({ pipeline_stage: newStage })
    .eq('id', candidateId)

  await supabase.from('stage_transitions').insert({
    candidate_id: candidateId,
    from_stage: fromStage,
    to_stage: newStage,
    reason: reason || null,
  })
}

export async function addCandidateNote(
  candidateId: string,
  content: string,
  authorId?: string
): Promise<void> {
  if (!isSupabaseConfigured || !supabase) {
    const candidate = mockCandidates.find(c => c.id === candidateId)
    if (candidate) {
      if (!candidate.notes) candidate.notes = []
      candidate.notes.push({
        id: crypto.randomUUID(),
        candidate_id: candidateId,
        author_id: authorId || 'demo',
        author_name: '大串',
        content,
        created_at: new Date().toISOString(),
      })
    }
    return
  }

  await supabase.from('candidate_notes').insert({
    candidate_id: candidateId,
    author_id: authorId,
    content,
  })
}

// ============================================
// Interviews
// ============================================

export async function getInterviews(): Promise<Interview[]> {
  if (!isSupabaseConfigured || !supabase) {
    return mockInterviews
  }

  const { data, error } = await supabase
    .from('interviews')
    .select('*, candidates(name), users(name)')
    .order('scheduled_at', { ascending: true })

  if (error) throw error
  return (data || []).map((d: any) => ({
    ...d,
    candidate_name: d.candidates?.name,
    interviewer_name: d.users?.name,
  }))
}

// ============================================
// Email Templates
// ============================================

export async function getEmailTemplates(): Promise<EmailTemplate[]> {
  if (!isSupabaseConfigured || !supabase) {
    return mockEmailTemplates
  }

  const { data, error } = await supabase
    .from('email_templates')
    .select('*')
    .order('created_at', { ascending: false })

  if (error) throw error
  return data || []
}

export async function createEmailTemplate(template: Partial<EmailTemplate>): Promise<EmailTemplate> {
  if (!isSupabaseConfigured || !supabase) {
    const newTemplate: EmailTemplate = {
      id: crypto.randomUUID(),
      name: template.name || '',
      type: template.type || 'auto_reply',
      subject: template.subject || '',
      body: template.body || '',
      variables_json: template.variables_json || {},
      usage_count: 0,
      created_at: new Date().toISOString(),
    }
    mockEmailTemplates.push(newTemplate)
    return newTemplate
  }

  const { data, error } = await supabase
    .from('email_templates')
    .insert(template)
    .select()
    .single()

  if (error) throw error
  return data
}

// ============================================
// Dashboard Stats
// ============================================

export async function getDashboardStats(): Promise<DashboardStats> {
  if (!isSupabaseConfigured || !supabase) {
    return mockDashboardStats
  }

  // Real implementation would aggregate from DB
  const { data: candidates } = await supabase.from('candidates').select('*')
  const allCandidates = candidates || []

  const now = new Date()
  const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
  const monthStart = new Date(now.getFullYear(), now.getMonth(), 1)

  const pipelineByStage: Record<string, number> = {}
  allCandidates.forEach((c: any) => {
    pipelineByStage[c.pipeline_stage] = (pipelineByStage[c.pipeline_stage] || 0) + 1
  })

  return {
    total_candidates: allCandidates.length,
    new_this_week: allCandidates.filter((c: any) => new Date(c.applied_at) >= weekAgo).length,
    interviews_this_week: 0, // would need interviews table
    offers_this_month: allCandidates.filter((c: any) =>
      c.pipeline_stage === 'offer' && new Date(c.updated_at) >= monthStart
    ).length,
    avg_screening_score: allCandidates.reduce((sum: number, c: any) => sum + (c.ai_screening_score || 0), 0) / Math.max(allCandidates.length, 1),
    pipeline_by_stage: pipelineByStage as DashboardStats['pipeline_by_stage'],
    funnel: mockDashboardStats.funnel, // simplified
    platform_performance: mockDashboardStats.platform_performance, // simplified
  }
}

前提条件
Tailwind CSS v4TypeScript 5
USE CASES
  • 採用管理
  • スカウト管理
  • 人材データベース

採用データベース

:LiTarget: 用途

応募者・選考・面接記録の構造化データベース。

:LiSparkle: 特徴

  • 応募者管理
  • 選考フロー
  • 面接記録
  • 評価データ

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

:LiInfo: lib/recruit-database.ts の中身そのもの。コピペ即可。

const isSupabaseConfigured = false; const supabase: any = null
import { mockCandidates, mockInterviews, mockEmailTemplates, mockDashboardStats } from './recruit-mock-data'
import type { Candidate, PipelineStageId, SourcePlatform, Interview, EmailTemplate, DashboardStats } from './recruit-types'

// ============================================
// Candidates
// ============================================

export async function getCandidates(filters?: {
  stage?: PipelineStageId | 'all'
  platform?: SourcePlatform | 'all'
  search?: string
  sortBy?: 'applied_at' | 'ai_screening_score'
}): Promise<Candidate[]> {
  if (!isSupabaseConfigured || !supabase) {
    let result = [...mockCandidates]
    if (filters?.stage && filters.stage !== 'all') {
      result = result.filter((c: any) => c.pipeline_stage === filters.stage)
    }
    if (filters?.platform && filters.platform !== 'all') {
      result = result.filter((c: any) => c.source_platform === filters.platform)
    }
    if (filters?.search) {
      const q = filters.search.toLowerCase()
      result = result.filter((c: any) =>
        c.name.toLowerCase().includes(q) ||
        c.email.toLowerCase().includes(q) ||
        c.phone.includes(q)
      )
    }
    result.sort((a, b) => {
      if (filters?.sortBy === 'ai_screening_score') {
        return (b.ai_screening_score ?? 0) - (a.ai_screening_score ?? 0)
      }
      return new Date(b.applied_at).getTime() - new Date(a.applied_at).getTime()
    })
    return result
  }

  let query = supabase.from('candidates').select('*, candidate_tags(*)')

  if (filters?.stage && filters.stage !== 'all') {
    query = query.eq('pipeline_stage', filters.stage)
  }
  if (filters?.platform && filters.platform !== 'all') {
    query = query.eq('source_platform', filters.platform)
  }
  if (filters?.search) {
    query = query.or(`name.ilike.%${filters.search}%,email.ilike.%${filters.search}%,phone.ilike.%${filters.search}%`)
  }
  if (filters?.sortBy === 'ai_screening_score') {
    query = query.order('ai_screening_score', { ascending: false, nullsFirst: false })
  } else {
    query = query.order('applied_at', { ascending: false })
  }

  const { data, error } = await query
  if (error) throw error
  return (data || []).map((d: any) => ({ ...d, tags: d.candidate_tags }))
}

export async function getCandidate(id: string): Promise<Candidate | null> {
  if (!isSupabaseConfigured || !supabase) {
    return mockCandidates.find(c => c.id === id) || null
  }

  const { data, error } = await supabase
    .from('candidates')
    .select('*, candidate_tags(*), candidate_notes(*)')
    .eq('id', id)
    .single()

  if (error) return null
  return { ...data, tags: data.candidate_tags, notes: data.candidate_notes }
}

export async function createCandidate(candidate: Partial<Candidate>): Promise<Candidate> {
  if (!isSupabaseConfigured || !supabase) {
    const newCandidate: Candidate = {
      id: crypto.randomUUID(),
      name: candidate.name || '',
      email: candidate.email || '',
      phone: candidate.phone || '',
      age: candidate.age || null,
      current_occupation: candidate.current_occupation || null,
      sales_experience_years: candidate.sales_experience_years || null,
      teleapo_experience: candidate.teleapo_experience || null,
      monthly_hours: candidate.monthly_hours || null,
      preferred_compensation: candidate.preferred_compensation || null,
      self_pr: candidate.self_pr || null,
      resume_url: candidate.resume_url || null,
      source_platform: candidate.source_platform || 'direct',
      source_url: candidate.source_url || null,
      utm_source: candidate.utm_source || null,
      utm_medium: candidate.utm_medium || null,
      ai_screening_score: null,
      ai_screening_reason: null,
      ai_matching_score: null,
      pipeline_stage: 'new',
      assigned_to: null,
      applied_at: new Date().toISOString(),
      created_at: new Date().toISOString(),
      updated_at: new Date().toISOString(),
    }
    mockCandidates.unshift(newCandidate)
    return newCandidate
  }

  const { data, error } = await supabase
    .from('candidates')
    .insert({
      ...candidate,
      pipeline_stage: 'new',
    })
    .select()
    .single()

  if (error) throw error
  return data
}

export async function updateCandidateStage(
  candidateId: string,
  newStage: PipelineStageId,
  reason?: string
): Promise<void> {
  if (!isSupabaseConfigured || !supabase) {
    const idx = mockCandidates.findIndex(c => c.id === candidateId)
    if (idx >= 0) {
      mockCandidates[idx].pipeline_stage = newStage
      mockCandidates[idx].updated_at = new Date().toISOString()
    }
    return
  }

  const { data: candidate } = await supabase
    .from('candidates')
    .select('pipeline_stage')
    .eq('id', candidateId)
    .single()

  const fromStage = candidate?.pipeline_stage || 'new'

  await supabase
    .from('candidates')
    .update({ pipeline_stage: newStage })
    .eq('id', candidateId)

  await supabase.from('stage_transitions').insert({
    candidate_id: candidateId,
    from_stage: fromStage,
    to_stage: newStage,
    reason: reason || null,
  })
}

export async function addCandidateNote(
  candidateId: string,
  content: string,
  authorId?: string
): Promise<void> {
  if (!isSupabaseConfigured || !supabase) {
    const candidate = mockCandidates.find(c => c.id === candidateId)
    if (candidate) {
      if (!candidate.notes) candidate.notes = []
      candidate.notes.push({
        id: crypto.randomUUID(),
        candidate_id: candidateId,
        author_id: authorId || 'demo',
        author_name: '大串',
        content,
        created_at: new Date().toISOString(),
      })
    }
    return
  }

  await supabase.from('candidate_notes').insert({
    candidate_id: candidateId,
    author_id: authorId,
    content,
  })
}

// ============================================
// Interviews
// ============================================

export async function getInterviews(): Promise<Interview[]> {
  if (!isSupabaseConfigured || !supabase) {
    return mockInterviews
  }

  const { data, error } = await supabase
    .from('interviews')
    .select('*, candidates(name), users(name)')
    .order('scheduled_at', { ascending: true })

  if (error) throw error
  return (data || []).map((d: any) => ({
    ...d,
    candidate_name: d.candidates?.name,
    interviewer_name: d.users?.name,
  }))
}

// ============================================
// Email Templates
// ============================================

export async function getEmailTemplates(): Promise<EmailTemplate[]> {
  if (!isSupabaseConfigured || !supabase) {
    return mockEmailTemplates
  }

  const { data, error } = await supabase
    .from('email_templates')
    .select('*')
    .order('created_at', { ascending: false })

  if (error) throw error
  return data || []
}

export async function createEmailTemplate(template: Partial<EmailTemplate>): Promise<EmailTemplate> {
  if (!isSupabaseConfigured || !supabase) {
    const newTemplate: EmailTemplate = {
      id: crypto.randomUUID(),
      name: template.name || '',
      type: template.type || 'auto_reply',
      subject: template.subject || '',
      body: template.body || '',
      variables_json: template.variables_json || {},
      usage_count: 0,
      created_at: new Date().toISOString(),
    }
    mockEmailTemplates.push(newTemplate)
    return newTemplate
  }

  const { data, error } = await supabase
    .from('email_templates')
    .insert(template)
    .select()
    .single()

  if (error) throw error
  return data
}

// ============================================
// Dashboard Stats
// ============================================

export async function getDashboardStats(): Promise<DashboardStats> {
  if (!isSupabaseConfigured || !supabase) {
    return mockDashboardStats
  }

  // Real implementation would aggregate from DB
  const { data: candidates } = await supabase.from('candidates').select('*')
  const allCandidates = candidates || []

  const now = new Date()
  const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
  const monthStart = new Date(now.getFullYear(), now.getMonth(), 1)

  const pipelineByStage: Record<string, number> = {}
  allCandidates.forEach((c: any) => {
    pipelineByStage[c.pipeline_stage] = (pipelineByStage[c.pipeline_stage] || 0) + 1
  })

  return {
    total_candidates: allCandidates.length,
    new_this_week: allCandidates.filter((c: any) => new Date(c.applied_at) >= weekAgo).length,
    interviews_this_week: 0, // would need interviews table
    offers_this_month: allCandidates.filter((c: any) =>
      c.pipeline_stage === 'offer' && new Date(c.updated_at) >= monthStart
    ).length,
    avg_screening_score: allCandidates.reduce((sum: number, c: any) => sum + (c.ai_screening_score || 0), 0) / Math.max(allCandidates.length, 1),
    pipeline_by_stage: pipelineByStage as DashboardStats['pipeline_by_stage'],
    funnel: mockDashboardStats.funnel, // simplified
    platform_performance: mockDashboardStats.platform_performance, // simplified
  }
}

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

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

:LiHandPointer: 使い方

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

:LiAlertCircle: 注意事項

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