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>
}
/>
動作確認
以下の順で確認してください。
管理者でログインして/admin/employeesにアクセス
新規登録ボタンから従業員を登録
登録した従業員が一覧に表示される
編集ボタンから情報を変更して保存
削除ボタンで論理削除→一覧から消える
一般社員でログインして/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アプリ
デスクトップアプリ
「こんなツールが欲しい」「業務効率化したいけど何から手をつければいいかわからない」など、お気軽にご相談ください。
皆さんのスキ・フォローが記事を書き続ける力になります。応援よろしくお願いします🔥
