Bài 30: Mini Project MVC – Website quản lý sinh viên hoàn chỉnh

Bài 1-29 đã dạy các thành phần MVC riêng lẻ. Bài 30 là mini project hoàn chỉnh — kết hợp tất cả: authentication, CRUD sinh viên/môn học/điểm, dashboard thống kê, phân quyền, export CSV. Bạn sẽ tổng hợp kiến thức, hiểu luồng dữ liệu end-to-end, và có một dự án thực tế để đưa vào portfolio.

1. Yêu cầu dự án

📋 Chức năng:

  • ✅ Đăng nhập/đăng ký, quên mật khẩu
  • ✅ Quản lý sinh viên (CRUD)
  • ✅ Quản lý môn học (CRUD)
  • ✅ Quản lý điểm (nhập, sửa, xóa)
  • ✅ Dashboard với thống kê
  • ✅ Export CSV, in danh sách
  • ✅ Phân quyền: admin/user
  • ✅ Search, filter, phân trang

2. Database Schema

-- Users
CREATE TABLE users (
    id INT PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(100),
    email VARCHAR(100) UNIQUE,
    password VARCHAR(255),
    role ENUM('user', 'admin') DEFAULT 'user',
    is_active TINYINT DEFAULT 1,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- Sinh viên
CREATE TABLE sinhvien (
    id INT PRIMARY KEY AUTO_INCREMENT,
    mssv VARCHAR(20) UNIQUE,
    ho_ten VARCHAR(100),
    email VARCHAR(100),
    lop VARCHAR(20),
    diem_tb DECIMAL(3,2),
    ghi_chu TEXT,
    user_id INT,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (user_id) REFERENCES users(id)
);

-- Môn học
CREATE TABLE monhoc (
    id INT PRIMARY KEY AUTO_INCREMENT,
    ma_mon VARCHAR(20) UNIQUE,
    ten_mon VARCHAR(100),
    so_tin_chi INT,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- Điểm
CREATE TABLE diem (
    id INT PRIMARY KEY AUTO_INCREMENT,
    sinh_vien_id INT,
    mon_hoc_id INT,
    diem_giua_ky DECIMAL(3,2),
    diem_cuoi_ky DECIMAL(3,2),
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (sinh_vien_id) REFERENCES sinhvien(id) ON DELETE CASCADE,
    FOREIGN KEY (mon_hoc_id) REFERENCES monhoc(id)
);

3. Routes

<?php
// File: config/routes.php (HOÀN CHỈNH)

use App\Core\Router;

$router = new Router();

// ===== PUBLIC ROUTES =====
$router->get('/', 'HomeController@index')->name('home');

// ===== AUTH ROUTES =====
$router->group(['middleware' => 'Guest'], function($r) {
    $r->get('/login', 'AuthController@loginForm')->name('login');
    $r->post('/login', 'AuthController@login');
    $r->get('/register', 'AuthController@registerForm')->name('register');
    $r->post('/register', 'AuthController@register');
});

// ===== USER ROUTES =====
$router->group(['middleware' => 'Auth'], function($r) {
    $r->get('/logout', 'AuthController@logout')->name('logout');
    $r->get('/profile', 'AuthController@profile')->name('profile');
    $r->post('/profile', 'AuthController@updateProfile');
    $r->post('/change-password', 'AuthController@changePassword');
    
    // Dashboard
    $r->get('/dashboard', 'DashboardController@index')->name('dashboard');
    
    // Sinh viên
    $r->get('/sinhvien', 'SinhVienController@index')->name('sinhvien.index');
    $r->get('/sinhvien/create', 'SinhVienController@create')->name('sinhvien.create');
    $r->post('/sinhvien', 'SinhVienController@store')->name('sinhvien.store');
    $r->get('/sinhvien/:id', 'SinhVienController@show')->name('sinhvien.show');
    $r->get('/sinhvien/:id/edit', 'SinhVienController@edit')->name('sinhvien.edit');
    $r->put('/sinhvien/:id', 'SinhVienController@update')->name('sinhvien.update');
    $r->delete('/sinhvien/:id', 'SinhVienController@destroy')->name('sinhvien.destroy');
    $r->get('/sinhvien/export/csv', 'SinhVienController@exportCsv')->name('sinhvien.export');
    
    // Môn học
    $r->get('/monhoc', 'MonHocController@index')->name('monhoc.index');
    $r->get('/monhoc/create', 'MonHocController@create')->name('monhoc.create');
    $r->post('/monhoc', 'MonHocController@store')->name('monhoc.store');
    $r->get('/monhoc/:id/edit', 'MonHocController@edit')->name('monhoc.edit');
    $r->put('/monhoc/:id', 'MonHocController@update')->name('monhoc.update');
    $r->delete('/monhoc/:id', 'MonHocController@destroy')->name('monhoc.destroy');
    
    // Điểm
    $r->get('/diem', 'DiemController@index')->name('diem.index');
    $r->get('/diem/nhap', 'DiemController@nhapDiem')->name('diem.nhap');
    $r->post('/diem/luu', 'DiemController@luuDiem')->name('diem.luu');
    $r->get('/diem/:id/edit', 'DiemController@edit')->name('diem.edit');
    $r->put('/diem/:id', 'DiemController@update')->name('diem.update');
    $r->delete('/diem/:id', 'DiemController@destroy')->name('diem.destroy');
});

// ===== ADMIN ROUTES =====
$router->group(['prefix' => 'admin', 'middleware' => 'Admin'], function($r) {
    $r->get('/dashboard', 'Admin\DashboardController@index')->name('admin.dashboard');
    $r->get('/users', 'Admin\UserController@index')->name('admin.users');
    $r->get('/users/:id/edit', 'Admin\UserController@edit');
    $r->put('/users/:id', 'Admin\UserController@update');
    $r->delete('/users/:id', 'Admin\UserController@destroy');
});

return $router;
?>

4. Dashboard Controller

<?php
// File: app/Controllers/DashboardController.php
namespace App\Controllers;

use App\Models\SinhVienModel;
use App\Models\DiemModel;
use App\Models\MonHocModel;

class DashboardController extends BaseController {
    protected SinhVienModel $sinhVienModel;
    protected DiemModel $diemModel;
    protected MonHocModel $monHocModel;
    
    public function __construct() {
        parent::__construct();
        $this->sinhVienModel = new SinhVienModel();
        $this->diemModel = new DiemModel();
        $this->monHocModel = new MonHocModel();
    }
    
    public function index(array $params): string {
        // Thống kê chung
        $stats = [
            'total_students' => $this->sinhVienModel->count(),
            'total_subjects' => $this->monHocModel->count(),
            'excellent' => count($this->sinhVienModel->scopeExcellent()),
            'good' => count($this->sinhVienModel->scopeGood()),
            'failed' => count($this->sinhVienModel->scopeFailed()),
        ];
        
        // Top 10 sinh viên xuất sắc
        $topStudents = array_slice($this->sinhVienModel->scopeExcellent(), 0, 10);
        
        // Thống kê theo lớp
        $classes = $this->sinhVienModel->getAllClasses();
        $classByStats = [];
        foreach ($classes as $class) {
            $students = $this->sinhVienModel->getByClass($class);
            $classByStats[$class] = count($students);
        }
        
        return $this->view('dashboard.index', [
            'title' => 'Dashboard',
            'stats' => $stats,
            'topStudents' => $topStudents,
            'classByStats' => $classByStats,
        ]);
    }
}
?>

5. Sinh Viên Controller

<?php
// File: app/Controllers/SinhVienController.php
namespace App\Controllers;

use App\Models\SinhVienModel;

class SinhVienController extends BaseController {
    protected SinhVienModel $model;
    
    public function __construct() {
        parent::__construct();
        $this->model = new SinhVienModel();
    }
    
    public function index(array $params): string {
        $page = (int)($_GET['page'] ?? 1);
        $keyword = trim($_GET['q'] ?? '');
        $class = trim($_GET['lop'] ?? '');
        
        if ($keyword) {
            $result = $this->model->search($keyword, $page);
        } elseif ($class) {
            $students = $this->model->getByClass($class);
            $result = [
                'items' => array_slice($students, ($page - 1) * 10, 10),
                'total' => count($students),
                'per_page' => 10,
                'current_page' => $page,
                'last_page' => ceil(count($students) / 10),
                'from' => (($page - 1) * 10) + 1,
                'to' => min($page * 10, count($students)),
            ];
        } else {
            $result = $this->model->paginate($page);
        }
        
        return $this->view('sinhvien.index', [
            'title' => 'Danh Sách Sinh Viên',
            'students' => $result['items'],
            'pagination' => $result,
            'keyword' => $keyword,
            'class' => $class,
            'classes' => $this->model->getAllClasses(),
        ]);
    }
    
    public function create(array $params): string {
        return $this->view('sinhvien.form', [
            'title' => 'Thêm Sinh Viên',
            'action' => route('sinhvien.store'),
            'method' => 'POST',
        ]);
    }
    
    public function store(array $params): void {
        $errors = $this->validate([
            'mssv' => 'required|max:20|unique:sinhvien',
            'ho_ten' => 'required|min:2|max:100',
            'email' => 'required|email|unique:sinhvien',
            'lop' => 'required|max:20',
        ]);
        
        if (!empty($errors)) {
            $this->withErrors($errors);
            setOldInput($_POST);
            $this->redirect(route('sinhvien.create'));
        }
        
        try {
            $data = $this->model->normalizeData($_POST);
            $id = $this->model->create($data);
            
            clearOldInput();
            flash('success', 'Thêm sinh viên thành công!');
            $this->redirect(route('sinhvien.show', ['id' => $id]));
        } catch (\Exception $e) {
            logMessage('ERROR', $e->getMessage());
            flash('error', 'Có lỗi xảy ra.');
            $this->back();
        }
    }
    
    public function show(array $params): string {
        $id = (int)$params['id'];
        $student = $this->model->find($id);
        
        if (!$student) {
            http_response_code(404);
            return view('errors.404', [], null);
        }
        
        $grades = $this->model->getDiem($id);
        
        return $this->view('sinhvien.detail', [
            'title' => 'Chi Tiết Sinh Viên',
            'student' => $student,
            'grades' => $grades,
        ]);
    }
    
    public function edit(array $params): string {
        $id = (int)$params['id'];
        $student = $this->model->find($id);
        
        if (!$student) {
            flash('error', 'Không tìm thấy sinh viên');
            $this->redirect(route('sinhvien.index'));
        }
        
        return $this->view('sinhvien.form', [
            'title' => 'Chỉnh Sửa Sinh Viên',
            'student' => $student,
            'action' => route('sinhvien.update', ['id' => $id]),
            'method' => 'PUT',
        ]);
    }
    
    public function update(array $params): void {
        $id = (int)$params['id'];
        $student = $this->model->find($id);
        
        if (!$student) {
            $this->redirect(route('sinhvien.index'));
        }
        
        $errors = $this->model->validate($_POST, $id);
        
        if (!empty($errors)) {
            $this->withErrors($errors);
            setOldInput($_POST);
            $this->redirect(route('sinhvien.edit', ['id' => $id]));
        }
        
        try {
            $data = $this->model->normalizeData($_POST);
            $this->model->update($id, $data);
            
            clearOldInput();
            flash('success', 'Cập nhật thành công!');
            $this->redirect(route('sinhvien.show', ['id' => $id]));
        } catch (\Exception $e) {
            logMessage('ERROR', $e->getMessage());
            flash('error', 'Có lỗi xảy ra.');
            $this->back();
        }
    }
    
    public function destroy(array $params): void {
        $id = (int)$params['id'];
        
        try {
            $this->model->delete($id);
            flash('success', 'Xóa sinh viên thành công!');
        } catch (\Exception $e) {
            logMessage('ERROR', $e->getMessage());
            flash('error', 'Không thể xóa sinh viên này.');
        }
        
        $this->redirect(route('sinhvien.index'));
    }
    
    public function exportCsv(array $params): void {
        $students = $this->model->all();
        
        header('Content-Type: text/csv; charset=utf-8');
        header('Content-Disposition: attachment; filename="sinhvien_' . date('Y-m-d') . '.csv"');
        
        $output = fopen('php://output', 'w');
        
        // BOM for UTF-8
        fprintf($output, chr(0xEF) . chr(0xBB) . chr(0xBF));
        
        // Headers
        fputcsv($output, ['MSSV', 'Họ Tên', 'Email', 'Lớp', 'Điểm TB']);
        
        // Data
        foreach ($students as $sv) {
            fputcsv($output, [
                $sv['mssv'],
                $sv['ho_ten'],
                $sv['email'],
                $sv['lop'],
                $sv['diem_tb'] ?? '',
            ]);
        }
        
        fclose($output);
        exit;
    }
}
?>

6. Điểm Controller

<?php
// File: app/Controllers/DiemController.php
namespace App\Controllers;

use App\Models\DiemModel;
use App\Models\SinhVienModel;
use App\Models\MonHocModel;

class DiemController extends BaseController {
    protected DiemModel $model;
    protected SinhVienModel $sinhVienModel;
    protected MonHocModel $monHocModel;
    
    public function __construct() {
        parent::__construct();
        $this->model = new DiemModel();
        $this->sinhVienModel = new SinhVienModel();
        $this->monHocModel = new MonHocModel();
    }
    
    public function index(array $params): string {
        $page = (int)($_GET['page'] ?? 1);
        
        $result = $this->model->paginate($page);
        
        return $this->view('diem.index', [
            'title' => 'Danh Sách Điểm',
            'grades' => $result['items'],
            'pagination' => $result,
        ]);
    }
    
    public function nhapDiem(array $params): string {
        $students = $this->sinhVienModel->all();
        $subjects = $this->monHocModel->all();
        
        return $this->view('diem.form', [
            'title' => 'Nhập Điểm',
            'students' => $students,
            'subjects' => $subjects,
        ]);
    }
    
    public function luuDiem(array $params): void {
        $sinhVienId = (int)($_POST['sinh_vien_id'] ?? 0);
        $monHocId = (int)($_POST['mon_hoc_id'] ?? 0);
        $diemGiuaKy = (float)($_POST['diem_giua_ky'] ?? 0);
        $diemCuoiKy = (float)($_POST['diem_cuoi_ky'] ?? 0);
        
        $errors = [];
        
        if (!$sinhVienId) {
            $errors['sinh_vien_id'] = 'Vui lòng chọn sinh viên';
        }
        
        if (!$monHocId) {
            $errors['mon_hoc_id'] = 'Vui lòng chọn môn học';
        }
        
        if ($diemGiuaKy < 0 || $diemGiuaKy > 10) {
            $errors['diem_giua_ky'] = 'Điểm giữa kỳ phải từ 0-10';
        }
        
        if ($diemCuoiKy < 0 || $diemCuoiKy > 10) {
            $errors['diem_cuoi_ky'] = 'Điểm cuối kỳ phải từ 0-10';
        }
        
        if (!empty($errors)) {
            $this->withErrors($errors);
            $this->back();
        }
        
        try {
            // Check nếu đã có điểm
            $existing = $this->model->findBySinhVienAndMonHoc($sinhVienId, $monHocId);
            
            if ($existing) {
                $this->model->update($existing['id'], [
                    'diem_giua_ky' => $diemGiuaKy,
                    'diem_cuoi_ky' => $diemCuoiKy,
                ]);
            } else {
                $this->model->create([
                    'sinh_vien_id' => $sinhVienId,
                    'mon_hoc_id' => $monHocId,
                    'diem_giua_ky' => $diemGiuaKy,
                    'diem_cuoi_ky' => $diemCuoiKy,
                ]);
            }
            
            // Cập nhật điểm TB sinh viên
            $grades = $this->model->getBySinhVien($sinhVienId);
            $diemTB = 0;
            if (!empty($grades)) {
                $total = 0;
                foreach ($grades as $grade) {
                    $total += $grade['diem_cuoi_ky'];
                }
                $diemTB = round($total / count($grades), 2);
            }
            
            $this->sinhVienModel->update($sinhVienId, ['diem_tb' => $diemTB]);
            
            flash('success', 'Lưu điểm thành công!');
            $this->redirect(route('diem.nhap'));
        } catch (\Exception $e) {
            logMessage('ERROR', $e->getMessage());
            flash('error', 'Có lỗi xảy ra.');
            $this->back();
        }
    }
    
    public function destroy(array $params): void {
        $id = (int)$params['id'];
        
        try {
            $grade = $this->model->find($id);
            if (!$grade) {
                flash('error', 'Không tìm thấy điểm');
                $this->redirect(route('diem.index'));
            }
            
            $this->model->delete($id);
            
            // Cập nhật lại điểm TB
            $grades = $this->model->getBySinhVien($grade['sinh_vien_id']);
            $diemTB = 0;
            if (!empty($grades)) {
                $total = 0;
                foreach ($grades as $g) {
                    $total += $g['diem_cuoi_ky'];
                }
                $diemTB = round($total / count($grades), 2);
            }
            
            $this->sinhVienModel->update($grade['sinh_vien_id'], ['diem_tb' => $diemTB]);
            
            flash('success', 'Xóa điểm thành công!');
        } catch (\Exception $e) {
            logMessage('ERROR', $e->getMessage());
            flash('error', 'Không thể xóa điểm này.');
        }
        
        $this->redirect(route('diem.index'));
    }
}
?>

7. Dashboard View

<?php
// File: app/Views/dashboard/index.php
?>
<style>
    .stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; margin-bottom: 2rem; }
    .stat-card { background: white; padding: 1.5rem; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
    .stat-card h3 { color: #666; font-size: 0.9rem; margin-bottom: 0.5rem; }
    .stat-card .number { font-size: 2rem; font-weight: bold; color: #2c3e50; }
    .stat-card.excellent .number { color: #2ecc71; }
    .stat-card.good .number { color: #3498db; }
    .stat-card.failed .number { color: #e74c3c; }
</style>

<div class="card">
    <h2>Dashboard</h2>
    
    <!-- Statistics -->
    <div class="stats-grid">
        <div class="stat-card">
            <h3>Tổng Sinh Viên</h3>
            <div class="number"><?php echo $stats['total_students']; ?></div>
        </div>
        
        <div class="stat-card">
            <h3>Tổng Môn Học</h3>
            <div class="number"><?php echo $stats['total_subjects']; ?></div>
        </div>
        
        <div class="stat-card excellent">
            <h3>Xuất Sắc</h3>
            <div class="number"><?php echo $stats['excellent']; ?></div>
        </div>
        
        <div class="stat-card good">
            <h3>Giỏi</h3>
            <div class="number"><?php echo $stats['good']; ?></div>
        </div>
        
        <div class="stat-card failed">
            <h3>Yếu</h3>
            <div class="number"><?php echo $stats['failed']; ?></div>
        </div>
    </div>
    
    <!-- Top students -->
    <div style="margin-top: 2rem;">
        <h3>Top 10 Sinh Viên Xuất Sắc</h3>
        <table style="width: 100%; margin-top: 1rem; border-collapse: collapse;">
            <thead>
                <tr style="background: #f8f9fa;">
                    <th style="padding: 0.75rem; text-align: left; border-bottom: 1px solid #ddd;">MSSV</th>
                    <th style="padding: 0.75rem; text-align: left; border-bottom: 1px solid #ddd;">Họ Tên</th>
                    <th style="padding: 0.75rem; text-align: left; border-bottom: 1px solid #ddd;">Lớp</th>
                    <th style="padding: 0.75rem; text-align: left; border-bottom: 1px solid #ddd;">Điểm TB</th>
                </tr>
            </thead>
            <tbody>
                <?php foreach ($topStudents as $sv): ?>
                    <tr style="border-bottom: 1px solid #ddd;">
                        <td style="padding: 0.75rem;"><?php echo e($sv['mssv']); ?></td>
                        <td style="padding: 0.75rem;"><?php echo e($sv['ho_ten']); ?></td>
                        <td style="padding: 0.75rem;"><?php echo e($sv['lop']); ?></td>
                        <td style="padding: 0.75rem; font-weight: bold; color: #2ecc71;"><?php echo $sv['diem_tb']; ?></td>
                    </tr>
                <?php endforeach; ?>
            </tbody>
        </table>
    </div>
</div>

8. Navigation

<?php
// File: app/Views/layouts/main.php (cập nhật navigation)

<nav>
    <a href="/">Trang chủ</a>
    
    <?php if (isAuthenticated()): ?>
        <a href="/dashboard">Dashboard</a>
        <a href="/sinhvien">Sinh viên</a>
        <a href="/monhoc">Môn học</a>
        <a href="/diem">Điểm</a>
        
        <?php if (isAdmin()): ?>
            <a href="/admin/dashboard">Quản trị</a>
            <a href="/admin/users">Users</a>
        <?php endif; ?>
        
        <div style="float: right;">
            <a href="/profile"><?php echo e(auth()['name']); ?></a>
            <a href="/logout">Đăng xuất</a>
        </div>
    <?php else: ?>
        <div style="float: right;">
            <a href="/login">Đăng nhập</a>
            <a href="/register">Đăng ký</a>
        </div>
    <?php endif; ?>
</nav>

Tóm tắt

  • ✅ Authentication: đăng nhập, đăng ký, phân quyền
  • ✅ CRUD đầy đủ: sinh viên, môn học, điểm
  • ✅ Dashboard với thống kê
  • ✅ Search, filter, phân trang
  • ✅ Export CSV
  • ✅ Validation, error handling
  • ✅ Responsive design
  • ✅ Production-ready structure

🎯 Bài tập cuối cùng

  1. Hoàn thiện project: Implement đầy đủ tất cả controllers, models, views. Test toàn bộ chức năng: CRUD, search, filter, export.
  2. Deploy: Deploy lên hosting (nếu có). Cấu hình database, .env, .htaccess. Test trên domain thực.
  3. Portfolio: Viết README, hướng dẫn cài đặt, tính năng. Đưa lên GitHub, thêm vào portfolio. Làm demo video.

Để lại bình luận

Email của bạn sẽ không được hiển thị.