見出し画像

Django+React ── 実務設計への道【第4回:従業員マスタのCRUD】

はじめに

前回はロールベースのアクセス制御を実装し、管理者と一般社員で見える画面を分離しました。

今回は管理者が従業員を登録・編集・削除できる画面を作ります。CRUDは地味に見えますが、実務システムの基本中の基本です。フォームのバリデーション・エラーハンドリング・APIとの連携を丁寧に実装します。


このシリーズはこちら


Django側:APIの実装

まず従業員マスタを操作するAPIを作ります。

シリアライザーの拡張

登録・編集用にパスワードを扱えるシリアライザーを追加します。

# accounts/serializers.py
from rest_framework import serializers
from .models import Employee


class EmployeeSerializer(serializers.ModelSerializer):
    class Meta:
        model = Employee
        fields = ['id', 'employee_code', 'name', 'email', 'department', 'is_manager']


class EmployeeCreateSerializer(serializers.ModelSerializer):
    password = serializers.CharField(write_only=True, min_length=8)

    class Meta:
        model = Employee
        fields = ['employee_code', 'name', 'email', 'department', 'is_manager', 'password']

    def create(self, validated_data):
        password = validated_data.pop('password')
        employee = Employee(**validated_data)
        employee.set_password(password)
        employee.save()
        return employee


class EmployeeUpdateSerializer(serializers.ModelSerializer):
    password = serializers.CharField(write_only=True, min_length=8, required=False)

    class Meta:
        model = Employee
        fields = ['employee_code', 'name', 'email', 'department', 'is_manager', 'password']

    def update(self, instance, validated_data):
        password = validated_data.pop('password', None)
        for attr, value in validated_data.items():
            setattr(instance, attr, value)
        if password:
            instance.set_password(password)
        instance.save()
        return instance

ポイント:シリアライザーを用途ごとに分ける理由

登録・編集・参照でフィールドの扱いが異なります。パスワードは登録時は必須ですが編集時は任意です。また参照時はパスワードを返したくありません。1つのシリアライザーに全部詰め込もうとするとロジックが複雑になるため、用途ごとに分けるのが実務の定番です。

ビューの実装

管理者のみ操作できるよう権限チェックを追加します。

# accounts/views.py(追記)
from rest_framework import generics, status
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
from .models import Employee
from .serializers import (
    EmployeeSerializer,
    EmployeeCreateSerializer,
    EmployeeUpdateSerializer,
)


class IsManager(IsAuthenticated):
    """管理者のみアクセス可能なパーミッション"""
    def has_permission(self, request, view):
        return super().has_permission(request, view) and request.user.is_manager


class EmployeeListCreateView(generics.ListCreateAPIView):
    queryset = Employee.objects.filter(is_active=True).order_by('employee_code')
    permission_classes = [IsManager]

    def get_serializer_class(self):
        if self.request.method == 'POST':
            return EmployeeCreateSerializer
        return EmployeeSerializer


class EmployeeRetrieveUpdateDestroyView(generics.RetrieveUpdateDestroyAPIView):
    queryset = Employee.objects.filter(is_active=True)
    permission_classes = [IsManager]

    def get_serializer_class(self):
        if self.request.method in ['PUT', 'PATCH']:
            return EmployeeUpdateSerializer
        return EmployeeSerializer

    def destroy(self, request, *args, **kwargs):
        # 物理削除ではなく論理削除
        instance = self.get_object()
        instance.is_active = False
        instance.save()
        return Response(status=status.HTTP_204_NO_CONTENT)

ポイント:物理削除ではなく論理削除にする理由

従業員を削除した場合でも、過去の打刻データや申請データは残しておく必要があります。物理削除すると外部キーが壊れてデータが消えてしまいます。is_active = Falseにするだけの論理削除にすることで、データの整合性を保ちます。

URLの追記

# accounts/urls.py(追記)
from django.urls import path
from rest_framework_simplejwt.views import TokenRefreshView
from .views import (
    CustomTokenObtainPairView,
    MeView,
    EmployeeListCreateView,
    EmployeeRetrieveUpdateDestroyView,
)

urlpatterns = [
    path('token/', CustomTokenObtainPairView.as_view(), name='token_obtain_pair'),
    path('token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
    path('me/', MeView.as_view(), name='me'),
    path('employees/', EmployeeListCreateView.as_view(), name='employee_list_create'),
    path('employees/<uuid:pk>/', EmployeeRetrieveUpdateDestroyView.as_view(), name='employee_detail'),
]

React側:従業員一覧画面

// src/pages/admin/Employees.jsx
import { useState, useEffect } from 'react'
import api from '../../api/axios'

export default function Employees() {
  const [employees, setEmployees] = useState([])
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState(null)
  const [showForm, setShowForm] = useState(false)
  const [editTarget, setEditTarget] = useState(null)

  const fetchEmployees = () => {
    setLoading(true)
    api.get('/auth/employees/')
      .then(res => setEmployees(res.data))
      .catch(() => setError('従業員データの取得に失敗しました'))
      .finally(() => setLoading(false))
  }

  useEffect(() => {
    fetchEmployees()
  }, [])

  const handleEdit = (employee) => {
    setEditTarget(employee)
    setShowForm(true)
  }

  const handleDelete = async (id, name) => {
    if (!window.confirm(`${name}を削除しますか?`)) return
    try {
      await api.delete(`/auth/employees/${id}/`)
      fetchEmployees()
    } catch {
      alert('削除に失敗しました')
    }
  }

  const handleFormClose = (refreshed) => {
    setShowForm(false)
    setEditTarget(null)
    if (refreshed) fetchEmployees()
  }

  if (loading) return <p style={{ padding: 24 }}>読み込み中...</p>
  if (error) return <p style={{ padding: 24, color: '#d32f2f' }}>{error}</p>

  return (
    <div style={{ padding: 24 }}>
      <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
        <h1 style={{ fontSize: 20 }}>従業員マスタ</h1>
        <button
          onClick={() => setShowForm(true)}
          style={{ padding: '8px 20px', background: '#1976d2', color: '#fff', border: 'none', borderRadius: 6, cursor: 'pointer' }}
        >
          + 新規登録
        </button>
      </div>

      {showForm && (
        <EmployeeForm
          employee={editTarget}
          onClose={handleFormClose}
        />
      )}

      <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
        <thead>
          <tr style={{ borderBottom: '2px solid #e0e0e0', textAlign: 'left' }}>
            <th style={{ padding: '10px 12px' }}>社員コード</th>
            <th style={{ padding: '10px 12px' }}>氏名</th>
            <th style={{ padding: '10px 12px' }}>メールアドレス</th>
            <th style={{ padding: '10px 12px' }}>部署</th>
            <th style={{ padding: '10px 12px' }}>権限</th>
            <th style={{ padding: '10px 12px' }}></th>
          </tr>
        </thead>
        <tbody>
          {employees.map(emp => (
            <tr key={emp.id} style={{ borderBottom: '1px solid #e0e0e0' }}>
              <td style={{ padding: '10px 12px' }}>{emp.employee_code}</td>
              <td style={{ padding: '10px 12px' }}>{emp.name}</td>
              <td style={{ padding: '10px 12px' }}>{emp.email}</td>
              <td style={{ padding: '10px 12px' }}>{emp.department || '—'}</td>
              <td style={{ padding: '10px 12px' }}>
                <span style={{
                  fontSize: 12,
                  padding: '2px 10px',
                  borderRadius: 99,
                  background: emp.is_manager ? '#e3f2fd' : '#f5f5f5',
                  color: emp.is_manager ? '#1565c0' : '#616161',
                }}>
                  {emp.is_manager ? '管理者' : '一般'}
                </span>
              </td>
              <td style={{ padding: '10px 12px', display: 'flex', gap: 8 }}>
                <button
                  onClick={() => handleEdit(emp)}
                  style={{ padding: '4px 12px', border: '1px solid #ccc', borderRadius: 4, cursor: 'pointer', fontSize: 13 }}
                >
                  編集
                </button>
                <button
                  onClick={() => handleDelete(emp.id, emp.name)}
                  style={{ padding: '4px 12px', border: '1px solid #ef9a9a', color: '#c62828', borderRadius: 4, cursor: 'pointer', fontSize: 13, background: 'transparent' }}
                >
                  削除
                </button>
              </td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  )
}

React側:従業員登録・編集フォーム

// src/pages/admin/Employees.jsx(EmployeeFormコンポーネントを追記)
function EmployeeForm({ employee, onClose }) {
  const isEdit = !!employee
  const [form, setForm] = useState({
    employee_code: employee?.employee_code || '',
    name: employee?.name || '',
    email: employee?.email || '',
    department: employee?.department || '',
    is_manager: employee?.is_manager || false,
    password: '',
  })
  const [errors, setErrors] = useState({})
  const [submitting, setSubmitting] = useState(false)

  const handleChange = (e) => {
    const { name, value, type, checked } = e.target
    setForm(prev => ({ ...prev, [name]: type === 'checkbox' ? checked : value }))
    setErrors(prev => ({ ...prev, [name]: null }))
  }

  const handleSubmit = async (e) => {
    e.preventDefault()
    setSubmitting(true)
    setErrors({})

    const payload = { ...form }
    // 編集時にパスワードが空なら送らない
    if (isEdit && !payload.password) delete payload.password

    try {
      if (isEdit) {
        await api.patch(`/auth/employees/${employee.id}/`, payload)
      } else {
        await api.post('/auth/employees/', payload)
      }
      onClose(true)
    } catch (err) {
      if (err.response?.data) {
        setErrors(err.response.data)
      } else {
        setErrors({ non_field_errors: ['保存に失敗しました'] })
      }
    } finally {
      setSubmitting(false)
    }
  }

  const fieldStyle = { width: '100%', padding: '8px 12px', border: '1px solid #ccc', borderRadius: 6, fontSize: 14, boxSizing: 'border-box' }
  const labelStyle = { display: 'block', fontSize: 13, marginBottom: 4, color: '#333' }
  const errorStyle = { fontSize: 12, color: '#d32f2f', marginTop: 4 }

  return (
    <div style={{ background: '#f9f9f9', border: '1px solid #e0e0e0', borderRadius: 8, padding: 24, marginBottom: 24 }}>
      <h2 style={{ fontSize: 16, marginBottom: 20 }}>{isEdit ? '従業員編集' : '従業員登録'}</h2>
      <form onSubmit={handleSubmit}>
        <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, marginBottom: 16 }}>
          <div>
            <label style={labelStyle}>社員コード *</label>
            <input name="employee_code" value={form.employee_code} onChange={handleChange} style={fieldStyle} required />
            {errors.employee_code && <p style={errorStyle}>{errors.employee_code[0]}</p>}
          </div>
          <div>
            <label style={labelStyle}>氏名 *</label>
            <input name="name" value={form.name} onChange={handleChange} style={fieldStyle} required />
            {errors.name && <p style={errorStyle}>{errors.name[0]}</p>}
          </div>
          <div>
            <label style={labelStyle}>メールアドレス *</label>
            <input type="email" name="email" value={form.email} onChange={handleChange} style={fieldStyle} required />
            {errors.email && <p style={errorStyle}>{errors.email[0]}</p>}
          </div>
          <div>
            <label style={labelStyle}>部署</label>
            <input name="department" value={form.department} onChange={handleChange} style={fieldStyle} />
          </div>
          <div>
            <label style={labelStyle}>パスワード {isEdit ? '(変更する場合のみ入力)' : '*'}</label>
            <input type="password" name="password" value={form.password} onChange={handleChange} style={fieldStyle} required={!isEdit} />
            {errors.password && <p style={errorStyle}>{errors.password[0]}</p>}
          </div>
          <div style={{ display: 'flex', alignItems: 'center', gap: 8, paddingTop: 24 }}>
            <input type="checkbox" name="is_manager" id="is_manager" checked={form.is_manager} onChange={handleChange} />
            <label htmlFor="is_manager" style={{ fontSize: 14, cursor: 'pointer' }}>管理者権限を付与する</label>
          </div>
        </div>

        {errors.non_field_errors && (
          <p style={{ ...errorStyle, marginBottom: 16 }}>{errors.non_field_errors[0]}</p>
        )}

        <div style={{ display: 'flex', gap: 12 }}>
          <button
            type="submit"
            disabled={submitting}
            style={{ padding: '8px 24px', background: '#1976d2', color: '#fff', border: 'none', borderRadius: 6, cursor: 'pointer', fontSize: 14 }}
          >
            {submitting ? '保存中...' : '保存'}
          </button>
          <button
            type="button"
            onClick={() => onClose(false)}
            style={{ padding: '8px 24px', background: 'transparent', border: '1px solid #ccc', borderRadius: 6, cursor: 'pointer', fontSize: 14 }}
          >
            キャンセル
          </button>
        </div>
      </form>
    </div>
  )
}

ポイント:DjangoのバリデーションエラーをReactで表示する

DRFはバリデーションエラーをフィールド名をキーとしたJSONで返します。

{
  "email": ["このメールアドレスはすでに使用されています。"],
  "employee_code": ["この社員コードはすでに使用されています。"]
}

err.response.dataでそのままオブジェクトを取り出し、errorsステートに格納することで各フィールドの下にエラーを表示できます。バックエンドのバリデーションをフロントに橋渡しするこのパターンは、どんな業務システムでも必ず使います。


ルーティングへの追加

// src/App.jsx(追記)
import Employees from './pages/admin/Employees'

// 管理者ルートに追加
<Route
  path="/admin/employees"
  element={
    <ProtectedRoute requireManager={true}>
      <Employees />
    </ProtectedRoute>
  }
/>

動作確認

以下の順で確認してください。

  1. 管理者でログインして/admin/employeesにアクセス

  2. 新規登録ボタンから従業員を登録

  3. 登録した従業員が一覧に表示される

  4. 編集ボタンから情報を変更して保存

  5. 削除ボタンで論理削除→一覧から消える

  6. 一般社員でログインして/admin/employeesにアクセス→社員ページにリダイレクトされる


まとめ

今回は以下のことを実装しました。

  • 用途別シリアライザーの設計(参照・登録・編集)

  • 論理削除による過去データの保護

  • カスタムパーミッションクラスによる管理者限定API

  • DRFのバリデーションエラーをReactで表示するパターン

  • 登録・編集を1つのフォームコンポーネントで兼用する設計

フォームの登録・編集兼用パターンとエラー表示は、このあとの有給申請・残業申請フォームでもそのまま使い回せます。


次回予告

第5回はQRコード生成です。従業員マスタと紐づいたユニークQRコードをDjangoで生成し、React上に表示します。第6回の打刻実装への橋渡しとなる回です。


交流しませんか?

Python・Django・JavaScript・React・製造業DX・業務効率化に関わっている方と交流したいと思っています。

気になる内容があった方、同じ分野で活動されている方、コメントやスキをいただけると嬉しいです。一緒に学んでいきましょう!


最後に

最後までご覧いただきありがとうございました!

Manufacturing DX Lab(MDL) では、製造業向けのDX支援・システム開発を行っています。

開発中プロダクト

  • ALISA:MRPベースのERP。BOM管理・生産指示・購買提案・PSI管理まで一気通貫

  • STELLA:AI搭載の在庫管理システム。K-meansによるロケーション分析など

ワンオフ製作も承っています

  • Excel VBAツール

  • 業務Webアプリ

  • デスクトップアプリ

「こんなツールが欲しい」「業務効率化したいけど何から手をつければいいかわからない」など、お気軽にご相談ください。

皆さんのスキ・フォローが記事を書き続ける力になります。応援よろしくお願いします🔥


このシリーズはこちら


いいなと思ったら応援しよう!