Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions src/components/admin/AdminHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import React from 'react';
import Link from 'next/link';
import { useRouter } from 'next/router';

type AdminHeaderProps = {
title: string;
};

export const AdminHeader = ({ title }: AdminHeaderProps) => {
const router = useRouter();

const handleLogout = () => {
localStorage.removeItem("adminToken");
router.push("/admin/login");
};

return (
<nav className="bg-gradient-to-r from-primary-light to-primary text-white p-4">
<div className="container mx-auto flex justify-between items-center">
<div className="text-xl font-bold">{title}</div>
<div className="flex items-center gap-6">
<Link href="/admin/candidates">
<span className="hover:underline">Ứng viên</span>
</Link>
<Link href="/admin/positions">
<span className="hover:underline">Vị trí</span>
</Link>
<button
onClick={handleLogout}
className="bg-white text-primary px-3 py-1 rounded hover:bg-gray-100"
>
Đăng xuất
</button>
</div>
</div>
</nav>
);
};
165 changes: 165 additions & 0 deletions src/components/modals/CreatePositionModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
"use client";
import React, { useState, useRef } from "react";
import { Input } from "@/components/ui/Input";
import { Select } from "@/components/ui/Select";
import positionService from "@/services/positionService";
import { Position } from "@/constants/position";
import { toast } from "react-toastify";
import "react-toastify/dist/ReactToastify.css";

type Props = {
onClose: () => void;
refreshPositions: () => void;
};

export default function CreatePositionModal({
onClose,
refreshPositions
}: Props) {
const formRef = useRef<HTMLFormElement>(null);
const [formData, setFormData] = useState<Partial<Position>>({
title: "",
description: "",
instruction: "",
level: 1,
slug: "",
is_active: true
});
const [forceValidate, setForceValidate] = useState(false);
const [isLoading, setIsLoading] = useState(false);

const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
};

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setForceValidate(true);

if (!formRef.current) return;

setForceValidate(true);

// Wait for validation to complete
await new Promise((resolve) => setTimeout(resolve, 100));

// Check for error messages
const errorElements = formRef.current.querySelectorAll(
".text-red-500.text-xs.mt-1",
);
const hasErrors = Array.from(errorElements).some((el) => {
return el.textContent && el.textContent.trim() !== "";
});

if (hasErrors) {
return;
}

try {
setIsLoading(true);
formData.slug = formData.title?.toLowerCase()
.replace(/[^\w\s]/g, '')
.replace(/\s+/g, '-') || "";
await positionService.createPosition(formData);
toast.success("Position created successfully");
refreshPositions();
onClose();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to create position");
} finally {
setIsLoading(false);
}
};

return (
<div className="fixed inset-0 bg-black/40 z-50 flex items-center justify-center">
<div className="bg-white rounded-lg p-6 w-full max-w-md">
<h2 className="text-xl font-bold mb-4">Thêm vị trí mới</h2>
<form ref={formRef} onSubmit={handleSubmit}>
<div className="space-y-4">
<Input
label="Tiêu đề"
name="title"
value={formData.title || ""}
onChange={handleChange}
rules={["required"]}
context={{ title: "Tiêu đề" }}
forceValidate={forceValidate}
/>

<Input
label="Cấp độ"
name="level"
value={formData.level || ""}
onChange={handleChange}
rules={["required", "number", { min: 1 }]}
context={{ title: "Cấp độ" }}
forceValidate={forceValidate}
/>


<Input
label="Mô tả"
name="description"
value={formData.description || ""}
onChange={handleChange}
rules={["required"]}
context={{ title: "Mô tả" }}
forceValidate={forceValidate }
/>

<Input
label="Hướng dẫn"
name="instruction"
rules={["required"]}
context={{ title: "Hướng dẫn" }}
value={formData.instruction || ""}
onChange={handleChange}
forceValidate={forceValidate}
/>

<Select
label="Trạng thái hoạt động"
name="is_active"
value={formData.is_active ? "true" : "false"}
onChange={(e) => {
setFormData(prev => ({
...prev,
is_active: e.target.value === "true"
}));
}}
options={[
{ value: "true", label: "Hoạt động" },
{ value: "false", label: "Không hoạt động" }
]}
/>

<div className="flex justify-end space-x-3">
<button
type="button"
onClick={onClose}
className="btn-secondary"
disabled={isLoading}
>
Hủy
</button>
<button
type="submit"
className="px-4 py-2 bg-primary text-white rounded hover:bg-primary-dark"
disabled={isLoading}
>
{isLoading ? "Thêm..." : "Thêm vị trí"}
</button>
</div>
</div>
</form>
</div>
</div>
);
}
80 changes: 80 additions & 0 deletions src/components/ui/Pagination.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import React from "react";

type PaginationProps = {
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
};

export default function Pagination({
currentPage,
totalPages,
onPageChange
}: PaginationProps) {
const getPageNumbers = () => {
const pages = [];
const maxVisiblePages = 7;

if (totalPages <= maxVisiblePages) {
for (let i = 1; i <= totalPages; i++) {
pages.push(i);
}
} else {
const half = Math.floor(maxVisiblePages / 2);
let start = currentPage - half;
let end = currentPage + half;

if (start < 1) {
start = 1;
end = maxVisiblePages;
} else if (end > totalPages) {
end = totalPages;
start = totalPages - maxVisiblePages + 1;
}

for (let i = start; i <= end; i++) {
pages.push(i);
}
}

return pages;
};

return (
<>
{totalPages > 1 && (
<div className="flex justify-end mt-4">
<div className="flex items-center gap-1">
<button
onClick={() => onPageChange(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
className="px-3 py-1 text-sm rounded border border-gray-300 bg-white text-gray-700 disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50 transition-colors"
>
Trước
</button>

{getPageNumbers().map((page) => (
<button
key={page}
onClick={() => onPageChange(page)}
className={`w-8 h-8 text-sm rounded ${currentPage === page
? 'bg-primary text-white'
: 'bg-white text-gray-700 border border-gray-300 hover:bg-gray-50'}`}
>
{page}
</button>
))}

<button
onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages}
className="px-3 py-1 text-sm rounded border border-gray-300 bg-white text-gray-700 disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50 transition-colors"
>
Sau
</button>
</div>
</div>
)}
</>
);
}
25 changes: 25 additions & 0 deletions src/constants/position.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
export interface Position {
_id: string;
slug: string;
title: string;
description: string;
instruction: string;
requirements: string[];
status: 'open' | 'closed' | 'on-hold';
level: number;
is_active: boolean;
createdAt?: string;
updatedAt?: string;
}

export const initialPosition: Position = {
_id: "",
slug: "",
title: "",
description: "",
instruction: "",
requirements: [],
status: 'open',
level: 1,
is_active: true,
};
2 changes: 1 addition & 1 deletion src/hooks/useCandidatesList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export const useCandidates = () => {
} finally {
setLoading(false);
}
}, []);
}, [pagination.currentPage, pagination.itemsPerPage]);

const handlePageChange = (page: number) => {
fetchCandidates(page, pagination.itemsPerPage);
Expand Down
Loading