successfully working CRUD with directus
This commit is contained in:
parent
24a979b364
commit
32830a9dd2
@ -1,16 +1,29 @@
|
||||
<template>
|
||||
<aside class="w-64 h-screen fixed bg-gray-900 text-white">
|
||||
<div class="p-4 text-xl font-bold border-b border-gray-700">
|
||||
🎓 StudentMS
|
||||
</div>
|
||||
<nav class="flex flex-col p-4 space-y-2">
|
||||
<NuxtLink to="/" class="p-2 rounded hover:bg-gray-800">Dashboard</NuxtLink>
|
||||
<NuxtLink to="/students" class="p-2 rounded hover:bg-gray-800">Students</NuxtLink>
|
||||
<NuxtLink to="/about" class="p-2 rounded hover:bg-gray-800">About</NuxtLink>
|
||||
</nav>
|
||||
<div class="p-4 border-t border-gray-700 text-sm mt-auto">
|
||||
© 2025 Atul Gunjal
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<aside class="w-64 h-screen fixed bg-blue-600 text-white">
|
||||
<!-- Sidebar Header -->
|
||||
<div class="p-4 text-xl font-bold border-b border-gray-700">
|
||||
🎓 StudentMS
|
||||
</div>
|
||||
|
||||
<!-- User Info Section (Optional) -->
|
||||
<div class="p-4 border-b border-gray-700 text-sm">
|
||||
<p class="font-semibold">Welcome, Atul!</p>
|
||||
<p class="text-gray-400">Admin</p>
|
||||
</div>
|
||||
|
||||
<!-- Navigation Links -->
|
||||
<nav class="flex flex-col p-4 space-y-2">
|
||||
<NuxtLink to="/Dashboard" class="p-2 rounded hover:bg-gray-800">🏠 Dashboard</NuxtLink>
|
||||
<NuxtLink to="/students" class="p-2 rounded hover:bg-gray-800">🎓 Students</NuxtLink>
|
||||
<NuxtLink to="/students/profile?id=1" class="p-2 rounded hover:bg-gray-800">👤 Profile</NuxtLink>
|
||||
<NuxtLink to="/students/settings" class="p-2 rounded hover:bg-gray-800">⚙️ Settings</NuxtLink>
|
||||
<NuxtLink to="/notifications" class="p-2 rounded hover:bg-gray-800">🔔 Notifications</NuxtLink>
|
||||
<NuxtLink to="/about" class="p-2 rounded hover:bg-gray-800">ℹ️ About</NuxtLink>
|
||||
</nav>
|
||||
|
||||
<!-- Footer Section -->
|
||||
<div class="p-4 border-t border-gray-700 text-sm mt-auto">
|
||||
© 2025 Atul Gunjal
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
106
frontend/app/components/attendance/AttendanceForm.vue
Normal file
106
frontend/app/components/attendance/AttendanceForm.vue
Normal file
@ -0,0 +1,106 @@
|
||||
<template>
|
||||
<div class="bg-white p-6 rounded-lg shadow-md max-w-lg mx-auto">
|
||||
<h2 class="text-2xl font-semibold text-gray-800 mb-4">Record Attendance</h2>
|
||||
|
||||
<form @submit.prevent="submitForm">
|
||||
<!-- Select Student -->
|
||||
<div class="mb-4">
|
||||
<label for="student" class="block text-sm font-medium text-gray-700">Select Student</label>
|
||||
<select
|
||||
v-model="attendance.studentId"
|
||||
id="student"
|
||||
class="mt-1 block w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-indigo-500 focus:border-indigo-500"
|
||||
required
|
||||
>
|
||||
<option disabled value="">Select a Student</option>
|
||||
<option v-for="student in students" :key="student.id" :value="student.id">
|
||||
{{ student.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Select Attendance Status -->
|
||||
<div class="mb-4">
|
||||
<label for="status" class="block text-sm font-medium text-gray-700">Attendance Status</label>
|
||||
<select
|
||||
v-model="attendance.status"
|
||||
id="status"
|
||||
class="mt-1 block w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-indigo-500 focus:border-indigo-500"
|
||||
required
|
||||
>
|
||||
<option value="present">Present</option>
|
||||
<option value="absent">Absent</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Date Input -->
|
||||
<div class="mb-4">
|
||||
<label for="date" class="block text-sm font-medium text-gray-700">Date</label>
|
||||
<input
|
||||
v-model="attendance.date"
|
||||
type="date"
|
||||
id="date"
|
||||
class="mt-1 block w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-indigo-500 focus:border-indigo-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
class="px-6 py-2 bg-indigo-600 text-white font-semibold rounded-lg hover:bg-indigo-700 focus:ring-2 focus:ring-indigo-500 focus:ring-opacity-50"
|
||||
>
|
||||
Record Attendance
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
// Props: Array of students passed to the component
|
||||
const props = defineProps({
|
||||
students: Array, // List of student objects { id, name }
|
||||
})
|
||||
|
||||
// Data model for attendance form
|
||||
const attendance = ref({
|
||||
studentId: '',
|
||||
status: 'present',
|
||||
date: new Date().toISOString().split('T')[0], // Set default to today’s date
|
||||
})
|
||||
|
||||
// Handle form submission
|
||||
const submitForm = () => {
|
||||
// This function should handle submitting the attendance data
|
||||
// You can make an API call or handle logic here
|
||||
console.log('Form Submitted:', attendance.value)
|
||||
|
||||
// Reset the form after submission
|
||||
attendance.value = {
|
||||
studentId: '',
|
||||
status: 'present',
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
form {
|
||||
max-width: 500px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
label {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
font-size: 1rem;
|
||||
}
|
||||
</style>
|
||||
|
82
frontend/app/components/attendance/AttendanceTable.vue
Normal file
82
frontend/app/components/attendance/AttendanceTable.vue
Normal file
@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<div class="bg-white p-4 rounded-lg shadow-md">
|
||||
<h2 class="text-xl font-semibold text-gray-800 mb-4">Attendance List</h2>
|
||||
|
||||
<table class="min-w-full table-auto border-collapse">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="px-4 py-2 text-left border-b">Student Name</th>
|
||||
<th class="px-4 py-2 text-left border-b">Attendance Status</th>
|
||||
<th class="px-4 py-2 text-left border-b">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="student in students" :key="student.id">
|
||||
<td class="px-4 py-2 border-b">{{ student.name }}</td>
|
||||
<td class="px-4 py-2 border-b">
|
||||
<span
|
||||
:class="{
|
||||
'bg-green-100 text-green-700': student.attendanceStatus === 'present',
|
||||
'bg-red-100 text-red-700': student.attendanceStatus === 'absent',
|
||||
}"
|
||||
class="px-3 py-1 rounded-full text-xs font-medium"
|
||||
>
|
||||
{{ capitalize(student.attendanceStatus) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-2 border-b">
|
||||
<button
|
||||
@click="toggleAttendance(student)"
|
||||
class="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition"
|
||||
>
|
||||
Toggle Status
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineProps } from 'vue'
|
||||
|
||||
// Props
|
||||
const props = defineProps({
|
||||
students: Array, // Array of student objects passed down to the component
|
||||
})
|
||||
|
||||
// Toggle Attendance Status (Helper function)
|
||||
const toggleAttendance = (student) => {
|
||||
student.attendanceStatus = student.attendanceStatus === 'present' ? 'absent' : 'present'
|
||||
}
|
||||
|
||||
// Capitalize Attendance Status
|
||||
const capitalize = (text) => {
|
||||
return text.charAt(0).toUpperCase() + text.slice(1)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
table {
|
||||
width: 100%;
|
||||
border-spacing: 0;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
th {
|
||||
background-color: #f7fafc;
|
||||
color: #2d3748;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
tr:hover {
|
||||
background-color: #f1f1f1;
|
||||
}
|
||||
</style>
|
||||
|
77
frontend/app/components/student/StudentCard.vue
Normal file
77
frontend/app/components/student/StudentCard.vue
Normal file
@ -0,0 +1,77 @@
|
||||
<template>
|
||||
<div class="bg-white p-4 rounded-lg shadow-lg hover:shadow-xl transition duration-200 ease-in-out">
|
||||
<div class="flex items-center space-x-4">
|
||||
<!-- Student Avatar -->
|
||||
<div class="w-16 h-16 bg-gray-300 rounded-full flex items-center justify-center text-white font-semibold">
|
||||
{{ student.name.charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
|
||||
<!-- Student Info -->
|
||||
<div>
|
||||
<h3 class="text-xl font-semibold text-gray-800">{{ student.name }}</h3>
|
||||
<p class="text-sm text-gray-500">{{ student.email }}</p>
|
||||
<p class="text-sm text-gray-500">{{ student.phone }}</p>
|
||||
<p class="text-sm text-gray-400">{{ formatDate(student.dob) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Class Info -->
|
||||
<div class="mt-4 flex justify-between">
|
||||
<div class="text-sm font-medium text-gray-600">
|
||||
Class: {{ student.class.name }} - {{ student.class.section }}
|
||||
</div>
|
||||
<div
|
||||
:class="{
|
||||
'bg-green-100 text-green-700': student.attendanceStatus === 'present',
|
||||
'bg-red-100 text-red-700': student.attendanceStatus === 'absent',
|
||||
}"
|
||||
class="px-3 py-1 rounded-full text-xs font-medium"
|
||||
>
|
||||
{{ capitalize(student.attendanceStatus) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- View Details Button -->
|
||||
<div class="mt-4">
|
||||
<NuxtLink
|
||||
:to="`/students/${student.id}`"
|
||||
class="inline-block px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition"
|
||||
>
|
||||
View Details
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineProps } from 'vue'
|
||||
|
||||
// Props
|
||||
const props = defineProps({
|
||||
student: Object, // The student object passed down to the component
|
||||
})
|
||||
|
||||
// Format Date (Helper function)
|
||||
const formatDate = (date) => {
|
||||
const options = { year: 'numeric', month: 'long', day: 'numeric' }
|
||||
return new Date(date).toLocaleDateString(undefined, options)
|
||||
}
|
||||
|
||||
// Capitalize Attendance Status
|
||||
const capitalize = (text) => {
|
||||
return text.charAt(0).toUpperCase() + text.slice(1)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Style the StudentCard for better UI */
|
||||
h3 {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
div.flex {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
</style>
|
||||
|
99
frontend/app/components/student/StudentDetails.vue
Normal file
99
frontend/app/components/student/StudentDetails.vue
Normal file
@ -0,0 +1,99 @@
|
||||
<template>
|
||||
<div class="bg-white p-6 rounded-lg shadow-md max-w-2xl mx-auto">
|
||||
<h2 class="text-2xl font-semibold text-gray-700 mb-4">
|
||||
Student Details: {{ student.name }}
|
||||
</h2>
|
||||
|
||||
<!-- Student Info -->
|
||||
<div class="space-y-4">
|
||||
<div class="flex justify-between">
|
||||
<div class="font-medium text-gray-600">Name:</div>
|
||||
<div class="text-gray-800">{{ student.name }}</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between">
|
||||
<div class="font-medium text-gray-600">Email:</div>
|
||||
<div class="text-gray-800">{{ student.email }}</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between">
|
||||
<div class="font-medium text-gray-600">Phone:</div>
|
||||
<div class="text-gray-800">{{ student.phone }}</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between">
|
||||
<div class="font-medium text-gray-600">Date of Birth:</div>
|
||||
<div class="text-gray-800">{{ formatDate(student.dob) }}</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between">
|
||||
<div class="font-medium text-gray-600">Gender:</div>
|
||||
<div class="text-gray-800">{{ capitalize(student.gender) }}</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between">
|
||||
<div class="font-medium text-gray-600">Class:</div>
|
||||
<div class="text-gray-800">{{ student.class.name }} - {{ student.class.section }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Attendance Info -->
|
||||
<div class="mt-6">
|
||||
<h3 class="text-xl font-semibold text-gray-700 mb-4">Attendance Records</h3>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div v-for="attendance in student.attendance" :key="attendance.id" class="flex justify-between">
|
||||
<div class="font-medium text-gray-600">{{ formatDate(attendance.date) }}:</div>
|
||||
<div class="text-gray-800">{{ attendance.status === 'present' ? 'Present' : 'Absent' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Button -->
|
||||
<div class="mt-6">
|
||||
<NuxtLink
|
||||
:to="`/students/edit/${student.id}`"
|
||||
class="px-6 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition"
|
||||
>
|
||||
Edit Student
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineProps } from 'vue'
|
||||
|
||||
// Props
|
||||
const props = defineProps({
|
||||
student: Object, // The student object containing all the details
|
||||
})
|
||||
|
||||
// Format Date (Helper function)
|
||||
const formatDate = (date) => {
|
||||
const options = { year: 'numeric', month: 'long', day: 'numeric' }
|
||||
return new Date(date).toLocaleDateString(undefined, options)
|
||||
}
|
||||
|
||||
// Capitalize first letter of gender
|
||||
const capitalize = (text) => {
|
||||
return text.charAt(0).toUpperCase() + text.slice(1)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Style the details section */
|
||||
h2 {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
div.flex {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
</style>
|
||||
|
74
frontend/app/components/student/StudentForm.vue
Normal file
74
frontend/app/components/student/StudentForm.vue
Normal file
@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<form @submit.prevent="submitForm">
|
||||
<div>
|
||||
<label for="name">Student Name</label>
|
||||
<input v-model="student.name" type="text" id="name" required />
|
||||
</div>
|
||||
<div>
|
||||
<label for="class_id">Class</label>
|
||||
<select v-model="student.class_id" id="class_id" required>
|
||||
<option value="" disabled>Select a class</option>
|
||||
<option v-for="classItem in classes" :key="classItem.id" :value="classItem.id">
|
||||
{{ classItem.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="email">Email</label>
|
||||
<input v-model="student.email" type="email" id="email" required />
|
||||
</div>
|
||||
<div>
|
||||
<label for="phone">Phone</label>
|
||||
<input v-model="student.phone" type="tel" id="phone" required />
|
||||
</div>
|
||||
<!-- Add other form fields here -->
|
||||
|
||||
<button type="submit">Save Student</button>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useDirectus } from '@/composables/useDirectus'
|
||||
|
||||
const { fetchCollection } = useDirectus()
|
||||
|
||||
const student = ref({
|
||||
name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
class_id: null, // Store selected class ID
|
||||
})
|
||||
|
||||
const classes = ref([]) // Array to store class options
|
||||
|
||||
onMounted(async () => {
|
||||
// Fetch the available classes from Directus
|
||||
classes.value = await fetchCollection('classes')
|
||||
})
|
||||
|
||||
const submitForm = async () => {
|
||||
// Code to handle form submission, create new student
|
||||
const newStudent = await createItem('students', student.value)
|
||||
if (newStudent) {
|
||||
console.log('Student added successfully:', newStudent)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useDirectus } from '~/composables/useDirectus'
|
||||
|
||||
const { getItems } = useDirectus()
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const response = await getItems('classes') // 'classes' is the collection name
|
||||
classes.value = response
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch classes:', err)
|
||||
}
|
||||
})
|
||||
|
||||
</script>
|
||||
|
97
frontend/app/components/student/StudentTable.vue
Normal file
97
frontend/app/components/student/StudentTable.vue
Normal file
@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<div class="overflow-x-auto bg-white p-4 rounded-lg shadow-md">
|
||||
<table class="min-w-full table-auto">
|
||||
<thead>
|
||||
<tr class="border-b">
|
||||
<th class="px-4 py-2 text-left text-sm font-semibold text-gray-600">ID</th>
|
||||
<th class="px-4 py-2 text-left text-sm font-semibold text-gray-600">Name</th>
|
||||
<th class="px-4 py-2 text-left text-sm font-semibold text-gray-600">Email</th>
|
||||
<th class="px-4 py-2 text-left text-sm font-semibold text-gray-600">Phone</th>
|
||||
<th class="px-4 py-2 text-left text-sm font-semibold text-gray-600">Class</th>
|
||||
<th class="px-4 py-2 text-left text-sm font-semibold text-gray-600">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- Loop through the students data -->
|
||||
<tr v-for="student in students" :key="student.id" class="border-b hover:bg-gray-50">
|
||||
<td class="px-4 py-2 text-sm text-gray-700">{{ student.id }}</td>
|
||||
<td class="px-4 py-2 text-sm text-gray-700">{{ student.name }}</td>
|
||||
<td class="px-4 py-2 text-sm text-gray-700">{{ student.email }}</td>
|
||||
<td class="px-4 py-2 text-sm text-gray-700">{{ student.phone }}</td>
|
||||
<td class="px-4 py-2 text-sm text-gray-700">{{ student.class_name }}</td>
|
||||
<td class="px-4 py-2 text-sm text-gray-700">
|
||||
<button @click="editStudent(student.id)" class="text-blue-500 hover:underline">Edit</button>
|
||||
<button @click="deleteStudent(student.id)" class="text-red-500 hover:underline ml-4">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<pagination :current-page="currentPage" :total-pages="totalPages" @page-changed="changePage" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import Pagination from '@/components/ui/Pagination.vue'
|
||||
|
||||
// Data for students (this will typically come from an API)
|
||||
const students = ref([
|
||||
{ id: 1, name: 'John Doe', email: 'john@example.com', phone: '123-456-7890', class_name: '10A' },
|
||||
{ id: 2, name: 'Jane Smith', email: 'jane@example.com', phone: '987-654-3210', class_name: '10B' },
|
||||
{ id: 3, name: 'Mary Johnson', email: 'mary@example.com', phone: '555-666-7777', class_name: '11A' },
|
||||
// Add more students as needed
|
||||
])
|
||||
|
||||
// Pagination state
|
||||
const currentPage = ref(1)
|
||||
const totalPages = computed(() => Math.ceil(students.value.length / 10)) // Assuming 10 students per page
|
||||
|
||||
// Change page handler
|
||||
const changePage = (newPage) => {
|
||||
currentPage.value = newPage
|
||||
// Here you can implement pagination logic to fetch data for the selected page
|
||||
}
|
||||
|
||||
// Edit student handler
|
||||
const editStudent = (id) => {
|
||||
console.log('Edit student with ID:', id)
|
||||
// You can redirect to an edit page or open a modal for editing
|
||||
// For example: router.push({ name: 'edit-student', params: { id } })
|
||||
}
|
||||
|
||||
// Delete student handler
|
||||
const deleteStudent = (id) => {
|
||||
console.log('Delete student with ID:', id)
|
||||
// Implement delete logic here (e.g., API call to delete the student)
|
||||
// After deletion, you can filter out the student from the list
|
||||
students.value = students.value.filter((student) => student.id !== id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
th {
|
||||
background-color: #f3f4f6;
|
||||
}
|
||||
|
||||
button {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
|
88
frontend/app/components/ui/Alert.vue
Normal file
88
frontend/app/components/ui/Alert.vue
Normal file
@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<!-- Alert Box -->
|
||||
<div v-if="isVisible" :class="alertClasses" class="flex items-center p-4 mb-4 border-l-4 rounded-lg shadow">
|
||||
<!-- Alert Icon -->
|
||||
<svg v-if="type === 'success'" class="w-5 h-5 text-green-500 mr-3" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<svg v-if="type === 'error'" class="w-5 h-5 text-red-500 mr-3" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
<svg v-if="type === 'warning'" class="w-5 h-5 text-yellow-500 mr-3" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v4m0 4h.01M19.5 12a7.5 7.5 0 11-15 0 7.5 7.5 0 0115 0z" />
|
||||
</svg>
|
||||
|
||||
<!-- Alert Message -->
|
||||
<span class="text-sm text-gray-700">{{ message }}</span>
|
||||
|
||||
<!-- Close Button -->
|
||||
<button @click="closeAlert" class="ml-auto text-gray-500 hover:text-gray-700">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" class="w-5 h-5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
// Props for the Alert component
|
||||
const props = defineProps({
|
||||
type: {
|
||||
type: String,
|
||||
default: 'success', // 'success', 'error', 'warning'
|
||||
},
|
||||
message: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
duration: {
|
||||
type: Number,
|
||||
default: 5000, // Duration before auto-hide
|
||||
},
|
||||
isVisible: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
// Emits to notify parent component to hide the alert
|
||||
const emit = defineEmits(['update:isVisible'])
|
||||
|
||||
// Internal ref to control visibility
|
||||
const isAlertVisible = ref(props.isVisible)
|
||||
|
||||
// Watch for changes in the `isVisible` prop
|
||||
watch(() => props.isVisible, (newVal) => {
|
||||
isAlertVisible.value = newVal
|
||||
if (newVal) {
|
||||
// Auto-hide after the given duration
|
||||
setTimeout(() => closeAlert(), props.duration)
|
||||
}
|
||||
})
|
||||
|
||||
// Alert Classes based on type
|
||||
const alertClasses = computed(() => {
|
||||
if (props.type === 'success') return 'bg-green-100 border-green-500 text-green-700'
|
||||
if (props.type === 'error') return 'bg-red-100 border-red-500 text-red-700'
|
||||
if (props.type === 'warning') return 'bg-yellow-100 border-yellow-500 text-yellow-700'
|
||||
return 'bg-blue-100 border-blue-500 text-blue-700'
|
||||
})
|
||||
|
||||
// Close alert function
|
||||
const closeAlert = () => {
|
||||
emit('update:isVisible', false) // Hide the alert
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
button:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
|
36
frontend/app/components/ui/Loader.vue
Normal file
36
frontend/app/components/ui/Loader.vue
Normal file
@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<!-- Loader Container -->
|
||||
<div v-if="isLoading" class="fixed inset-0 bg-gray-500 bg-opacity-50 flex justify-center items-center z-50">
|
||||
<!-- Loader Spinner -->
|
||||
<div class="w-16 h-16 border-t-4 border-blue-500 border-solid rounded-full animate-spin"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
// Props to control loader visibility
|
||||
const props = defineProps({
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Styling for the loader spinner */
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
div {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
</style>
|
||||
|
68
frontend/app/components/ui/Modal.vue
Normal file
68
frontend/app/components/ui/Modal.vue
Normal file
@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<!-- Modal Backdrop -->
|
||||
<div v-if="isOpen" class="fixed inset-0 bg-gray-500 bg-opacity-50 flex justify-center items-center z-50">
|
||||
<!-- Modal Content -->
|
||||
<div class="bg-white rounded-lg w-full max-w-md p-6">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<!-- Modal Header -->
|
||||
<h2 class="text-xl font-semibold text-gray-800">{{ title }}</h2>
|
||||
<button @click="close" class="text-gray-500 hover:text-gray-800">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" class="w-5 h-5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Modal Body (can be passed as slot) -->
|
||||
<div class="mb-4">
|
||||
<slot></slot>
|
||||
</div>
|
||||
|
||||
<!-- Modal Footer (Buttons) -->
|
||||
<div class="flex justify-end space-x-4">
|
||||
<button @click="close" class="px-4 py-2 bg-gray-300 rounded-lg text-gray-700 hover:bg-gray-400">Cancel</button>
|
||||
<button @click="confirmAction" class="px-4 py-2 bg-blue-500 rounded-lg text-white hover:bg-blue-600">Confirm</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
// Props for passing title and handling actions
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: 'Modal Title'
|
||||
},
|
||||
isOpen: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
// Emits to communicate with the parent component
|
||||
const emit = defineEmits(['update:isOpen', 'confirm'])
|
||||
|
||||
const close = () => {
|
||||
emit('update:isOpen', false) // Close modal when 'Cancel' or close button is clicked
|
||||
}
|
||||
|
||||
const confirmAction = () => {
|
||||
emit('confirm') // Trigger confirmation action (like saving or deleting data)
|
||||
close() // Close the modal after confirming
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Modal Styling */
|
||||
button:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
|
72
frontend/app/components/ui/Pagination.vue
Normal file
72
frontend/app/components/ui/Pagination.vue
Normal file
@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-between p-4 bg-white border-t border-gray-300 rounded-b-lg">
|
||||
<!-- Previous Page Button -->
|
||||
<button
|
||||
@click="goToPage(currentPage - 1)"
|
||||
:disabled="currentPage === 1"
|
||||
class="px-4 py-2 text-sm font-semibold text-white bg-blue-500 rounded-lg hover:bg-blue-600 disabled:bg-gray-300"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
|
||||
<!-- Page Numbers -->
|
||||
<div class="flex space-x-2">
|
||||
<button
|
||||
v-for="page in totalPages"
|
||||
:key="page"
|
||||
@click="goToPage(page)"
|
||||
:class="{'bg-blue-500 text-white': currentPage === page, 'bg-gray-100': currentPage !== page}"
|
||||
class="px-4 py-2 text-sm font-semibold rounded-lg hover:bg-blue-200"
|
||||
>
|
||||
{{ page }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Next Page Button -->
|
||||
<button
|
||||
@click="goToPage(currentPage + 1)"
|
||||
:disabled="currentPage === totalPages"
|
||||
class="px-4 py-2 text-sm font-semibold text-white bg-blue-500 rounded-lg hover:bg-blue-600 disabled:bg-gray-300"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
// Props to accept pagination data
|
||||
const props = defineProps({
|
||||
totalItems: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
itemsPerPage: {
|
||||
type: Number,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const currentPage = ref(1)
|
||||
|
||||
// Compute total number of pages
|
||||
const totalPages = computed(() => Math.ceil(props.totalItems / props.itemsPerPage))
|
||||
|
||||
// Go to specific page
|
||||
const goToPage = (page) => {
|
||||
if (page >= 1 && page <= totalPages.value) {
|
||||
currentPage.value = page
|
||||
// Emit an event or perform an action to fetch the data for the selected page
|
||||
// e.g., fetchPageData(currentPage.value)
|
||||
console.log(`Fetching data for page: ${currentPage.value}`)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
|
91
frontend/app/components/ui/SearchBar.vue
Normal file
91
frontend/app/components/ui/SearchBar.vue
Normal file
@ -0,0 +1,91 @@
|
||||
<template>
|
||||
<div class="relative w-full max-w-lg mx-auto">
|
||||
<!-- Search Input -->
|
||||
<input
|
||||
v-model="query"
|
||||
@input="onSearch"
|
||||
type="text"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Search students, classes, or anything..."
|
||||
/>
|
||||
|
||||
<!-- Search Icon -->
|
||||
<div v-if="query" class="absolute right-4 top-2 text-gray-500 cursor-pointer" @click="clearSearch">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
class="w-5 h-5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Search Suggestions (Optional, can be enhanced with data) -->
|
||||
<div v-if="results.length > 0 && query" class="absolute mt-2 w-full bg-white border border-gray-300 rounded-lg shadow-md">
|
||||
<ul>
|
||||
<li
|
||||
v-for="result in results"
|
||||
:key="result.id"
|
||||
class="p-2 cursor-pointer hover:bg-gray-100"
|
||||
@click="onSelect(result)"
|
||||
>
|
||||
{{ result.name }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const query = ref('')
|
||||
const results = ref([])
|
||||
|
||||
// Dummy data for search suggestions (replace this with API results or store data)
|
||||
const allStudents = [
|
||||
{ id: 1, name: 'John Doe' },
|
||||
{ id: 2, name: 'Jane Smith' },
|
||||
{ id: 3, name: 'Robert Johnson' },
|
||||
{ id: 4, name: 'Emily Davis' }
|
||||
]
|
||||
|
||||
const onSearch = () => {
|
||||
if (query.value) {
|
||||
// Simulate search functionality (replace with API call or real data filtering)
|
||||
results.value = allStudents.filter(student =>
|
||||
student.name.toLowerCase().includes(query.value.toLowerCase())
|
||||
)
|
||||
} else {
|
||||
results.value = []
|
||||
}
|
||||
}
|
||||
|
||||
const clearSearch = () => {
|
||||
query.value = ''
|
||||
results.value = []
|
||||
}
|
||||
|
||||
const onSelect = (result) => {
|
||||
// Handle selection (e.g., redirect to student profile or perform other actions)
|
||||
console.log('Selected:', result)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Additional styling for the search bar */
|
||||
input {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
input:focus {
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
</style>
|
||||
|
62
frontend/app/composables/useDirectus.js
Normal file
62
frontend/app/composables/useDirectus.js
Normal file
@ -0,0 +1,62 @@
|
||||
// frontend/composables/useDirectus.js
|
||||
import axios from 'axios'
|
||||
|
||||
const DIRECTUS_API_URL = '/api/directus'
|
||||
// Create a reusable axios instance with default config
|
||||
const directusApi = axios.create({
|
||||
baseURL: DIRECTUS_API_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
// Add this if you're using static token authentication
|
||||
// 'Authorization': `Bearer YOUR_STATIC_TOKEN`
|
||||
}
|
||||
})
|
||||
|
||||
export const useDirectus = () => {
|
||||
const fetchCollection = async (collection, params = {}) => {
|
||||
try {
|
||||
const response = await directusApi.get(`/items/${collection}`, { params })
|
||||
return response.data.data
|
||||
} catch (error) {
|
||||
console.error('Error fetching data from Directus:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const createItem = async (collection, itemData) => {
|
||||
try {
|
||||
const response = await directusApi.post(`/items/${collection}`, itemData)
|
||||
return response.data.data
|
||||
} catch (error) {
|
||||
console.error('Error creating item in Directus:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const updateItem = async (collection, itemId, itemData) => {
|
||||
try {
|
||||
const response = await directusApi.patch(`/items/${collection}/${itemId}`, itemData)
|
||||
return response.data.data
|
||||
} catch (error) {
|
||||
console.error('Error updating item in Directus:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const deleteItem = async (collection, itemId) => {
|
||||
try {
|
||||
const response = await directusApi.delete(`/items/${collection}/${itemId}`)
|
||||
return response.data.data
|
||||
} catch (error) {
|
||||
console.error('Error deleting item from Directus:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
fetchCollection,
|
||||
createItem,
|
||||
updateItem,
|
||||
deleteItem
|
||||
}
|
||||
}
|
24
frontend/app/server/middleware/directus-proxy.js
Normal file
24
frontend/app/server/middleware/directus-proxy.js
Normal file
@ -0,0 +1,24 @@
|
||||
// server/middleware/directus-proxy.js
|
||||
import { createProxyMiddleware } from 'http-proxy-middleware'
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
const proxy = createProxyMiddleware({
|
||||
target: 'http://localhost:8055', // Your Directus URL
|
||||
changeOrigin: true,
|
||||
pathRewrite: {
|
||||
'^/api/directus': '', // Remove `/api/directus` when forwarding to Directus
|
||||
},
|
||||
onProxyReq(proxyReq) {
|
||||
// Add auth headers if needed (e.g., static token)
|
||||
proxyReq.setHeader('Authorization', 'Bearer YOUR_STATIC_TOKEN')
|
||||
},
|
||||
})
|
||||
|
||||
// Forward the request
|
||||
return new Promise((resolve, reject) => {
|
||||
proxy(event.node.req, event.node.res, (err) => {
|
||||
if (err) reject(err)
|
||||
else resolve()
|
||||
})
|
||||
})
|
||||
})
|
16
frontend/docker-compose.yml
Normal file
16
frontend/docker-compose.yml
Normal file
@ -0,0 +1,16 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
directus:
|
||||
image: directus/directus:latest
|
||||
ports:
|
||||
- 8055:8055
|
||||
environment:
|
||||
KEY: your-secret-key
|
||||
ADMIN_EMAIL: admin@example.com
|
||||
ADMIN_PASSWORD: admin123
|
||||
volumes:
|
||||
- directus_data:/data
|
||||
|
||||
volumes:
|
||||
directus_data:
|
@ -8,4 +8,14 @@ export default defineNuxtConfig({
|
||||
tailwindcss(),
|
||||
],
|
||||
},
|
||||
|
||||
// proxy api
|
||||
nitro: {
|
||||
devProxy: {
|
||||
'/api/directus': {
|
||||
target: 'http://localhost:8055',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
199
frontend/package-lock.json
generated
199
frontend/package-lock.json
generated
@ -7,10 +7,13 @@
|
||||
"name": "nuxt-app",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@directus/sdk": "^18.0.3",
|
||||
"@nuxtjs/auth-next": "^5.0.0-1667386184.dfbbb54",
|
||||
"@tailwindcss/vite": "^4.1.4",
|
||||
"appwrite": "^17.0.2",
|
||||
"axios": "^1.8.4",
|
||||
"chart.js": "^4.4.9",
|
||||
"http-proxy-middleware": "^3.0.5",
|
||||
"nuxt": "^3.16.2",
|
||||
"tailwindcss": "^4.1.4",
|
||||
"vue": "^3.5.13",
|
||||
@ -487,6 +490,18 @@
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@directus/sdk": {
|
||||
"version": "18.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@directus/sdk/-/sdk-18.0.3.tgz",
|
||||
"integrity": "sha512-PnEDRDqr2x/DG3HZ3qxU7nFp2nW6zqJqswjii57NhriXgTz4TBUI8NmSdzQvnyHuTL9J0nedYfQGfW4v8odS1A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/directus/directus?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.3.tgz",
|
||||
@ -2306,6 +2321,15 @@
|
||||
"vue": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@nuxtjs/auth-next/node_modules/axios": {
|
||||
"version": "0.26.1",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-0.26.1.tgz",
|
||||
"integrity": "sha512-fPwcX4EvnSHuInCMItEhAGnaSEXRBjtzh9fOtsE6E1G6p7vl7edEeZe11QHf18+6+9gR5PbKV/sGKNaD8YaMeA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.14.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@nuxtjs/auth-next/node_modules/consola": {
|
||||
"version": "2.15.3",
|
||||
"resolved": "https://registry.npmjs.org/consola/-/consola-2.15.3.tgz",
|
||||
@ -2376,6 +2400,34 @@
|
||||
"http-proxy-middleware": "^1.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@nuxtjs/proxy/node_modules/http-proxy-middleware": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-1.3.1.tgz",
|
||||
"integrity": "sha512-13eVVDYS4z79w7f1+NPllJtOQFx/FdUW4btIvVRMaRlUY9VGstAbo5MOhLEuUgZFRHn3x50ufn25zkj/boZnEg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/http-proxy": "^1.17.5",
|
||||
"http-proxy": "^1.18.1",
|
||||
"is-glob": "^4.0.1",
|
||||
"is-plain-obj": "^3.0.0",
|
||||
"micromatch": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nuxtjs/proxy/node_modules/is-plain-obj": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-3.0.0.tgz",
|
||||
"integrity": "sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-darwin-arm64": {
|
||||
"version": "0.56.5",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.56.5.tgz",
|
||||
@ -4561,6 +4613,12 @@
|
||||
"integrity": "sha512-tLRNUXati5MFePdAk8dw7Qt7DpxPB60ofAgn8WRhW6a2rcimZnYBP9oxHiv0OHy+Wz7kPMG+t4LGdt31+4EmGg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/autoprefixer": {
|
||||
"version": "10.4.21",
|
||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz",
|
||||
@ -4599,12 +4657,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "0.26.1",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-0.26.1.tgz",
|
||||
"integrity": "sha512-fPwcX4EvnSHuInCMItEhAGnaSEXRBjtzh9fOtsE6E1G6p7vl7edEeZe11QHf18+6+9gR5PbKV/sGKNaD8YaMeA==",
|
||||
"version": "1.8.4",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.8.4.tgz",
|
||||
"integrity": "sha512-eBSYY4Y68NNlHbHBMdeDmKNtDgXWhQsJcGqzO3iLUM0GraQFSS9cVgPX5I9b3lbdFKyYoAEGAZF1DwhTaljNAw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.14.8"
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.0",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/axios-retry": {
|
||||
@ -5140,6 +5200,18 @@
|
||||
"text-hex": "1.0.x"
|
||||
}
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "10.0.1",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz",
|
||||
@ -5681,6 +5753,15 @@
|
||||
"integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/delegates": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
|
||||
@ -6114,6 +6195,21 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-set-tostringtag": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.6",
|
||||
"has-tostringtag": "^1.0.2",
|
||||
"hasown": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.25.2",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.2.tgz",
|
||||
@ -6584,6 +6680,42 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz",
|
||||
"integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/form-data/node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/form-data/node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/formdata-polyfill": {
|
||||
"version": "4.0.10",
|
||||
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
|
||||
@ -7027,6 +7159,21 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-tostringtag": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-symbols": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-unicode": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
|
||||
@ -7137,31 +7284,20 @@
|
||||
}
|
||||
},
|
||||
"node_modules/http-proxy-middleware": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-1.3.1.tgz",
|
||||
"integrity": "sha512-13eVVDYS4z79w7f1+NPllJtOQFx/FdUW4btIvVRMaRlUY9VGstAbo5MOhLEuUgZFRHn3x50ufn25zkj/boZnEg==",
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-3.0.5.tgz",
|
||||
"integrity": "sha512-GLZZm1X38BPY4lkXA01jhwxvDoOkkXqjgVyUzVxiEK4iuRu03PZoYHhHRwxnfhQMDuaxi3vVri0YgSro/1oWqg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/http-proxy": "^1.17.5",
|
||||
"@types/http-proxy": "^1.17.15",
|
||||
"debug": "^4.3.6",
|
||||
"http-proxy": "^1.18.1",
|
||||
"is-glob": "^4.0.1",
|
||||
"is-plain-obj": "^3.0.0",
|
||||
"micromatch": "^4.0.2"
|
||||
"is-glob": "^4.0.3",
|
||||
"is-plain-object": "^5.0.0",
|
||||
"micromatch": "^4.0.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/http-proxy-middleware/node_modules/is-plain-obj": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-3.0.0.tgz",
|
||||
"integrity": "sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/http-shutdown": {
|
||||
@ -7493,6 +7629,15 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-plain-object": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
|
||||
"integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-reference": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz",
|
||||
@ -10060,6 +10205,12 @@
|
||||
"integrity": "sha512-hHVTzba3wboROl0/aWRRG9dMytgH6ow//STBZh43l/wQgmMhYhOFi0EHWAPtoCz9IAUymsyP0TSBHkhgMEGNnQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pump": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz",
|
||||
|
@ -10,10 +10,13 @@
|
||||
"postinstall": "nuxt prepare"
|
||||
},
|
||||
"dependencies": {
|
||||
"@directus/sdk": "^18.0.3",
|
||||
"@nuxtjs/auth-next": "^5.0.0-1667386184.dfbbb54",
|
||||
"@tailwindcss/vite": "^4.1.4",
|
||||
"appwrite": "^17.0.2",
|
||||
"axios": "^1.8.4",
|
||||
"chart.js": "^4.4.9",
|
||||
"http-proxy-middleware": "^3.0.5",
|
||||
"nuxt": "^3.16.2",
|
||||
"tailwindcss": "^4.1.4",
|
||||
"vue": "^3.5.13",
|
||||
|
@ -3,32 +3,42 @@
|
||||
<h1 class="text-3xl font-bold text-gray-800 mb-6">Welcome to Your Dashboard</h1>
|
||||
|
||||
<div class="flex flex-wrap gap-6">
|
||||
<!-- Card 1 -->
|
||||
<div class="flex-1 min-w-[250px] bg-white p-6 rounded-2xl shadow hover:shadow-md transition">
|
||||
<h2 class="text-xl font-semibold text-gray-700 mb-2">Profile</h2>
|
||||
<!-- Profile Card -->
|
||||
<NuxtLink
|
||||
to="/students/profile?id=1"
|
||||
class="flex-1 min-w-[250px] bg-white p-6 rounded-2xl shadow hover:shadow-md transition cursor-pointer hover:bg-blue-50"
|
||||
>
|
||||
<h2 class="text-xl font-semibold text-gray-700 mb-2">👤 Profile</h2>
|
||||
<p class="text-gray-500">View and edit your profile info.</p>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
|
||||
<!-- Card 2 -->
|
||||
<div class="flex-1 min-w-[250px] bg-white p-6 rounded-2xl shadow hover:shadow-md transition">
|
||||
<h2 class="text-xl font-semibold text-gray-700 mb-2">Notifications</h2>
|
||||
<!-- Notifications Card -->
|
||||
<NuxtLink
|
||||
to="/notifications"
|
||||
class="flex-1 min-w-[250px] bg-white p-6 rounded-2xl shadow hover:shadow-md transition cursor-pointer hover:bg-blue-50"
|
||||
>
|
||||
<h2 class="text-xl font-semibold text-gray-700 mb-2">🔔 Notifications</h2>
|
||||
<p class="text-gray-500">Check latest alerts and messages.</p>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
|
||||
<!-- Card 3 -->
|
||||
<div class="flex-1 min-w-[250px] bg-white p-6 rounded-2xl shadow hover:shadow-md transition">
|
||||
<h2 class="text-xl font-semibold text-gray-700 mb-2">Settings</h2>
|
||||
<p class="text-gray-500">Manage account preferences.</p>
|
||||
</div>
|
||||
<!-- Settings Card -->
|
||||
<NuxtLink
|
||||
to="/students/settings"
|
||||
class="flex-1 min-w-[250px] bg-white p-6 rounded-2xl shadow hover:shadow-md transition cursor-pointer hover:bg-blue-50"
|
||||
>
|
||||
<h2 class="text-xl font-semibold text-gray-700 mb-2">⚙️ Settings</h2>
|
||||
<p class="text-gray-500">Manage student-related preferences.</p>
|
||||
</NuxtLink>
|
||||
|
||||
<!-- Card 4 -->
|
||||
<div class="flex-1 min-w-[250px] bg-white p-6 rounded-2xl shadow hover:shadow-md transition">
|
||||
<h2 class="text-xl font-semibold text-gray-700 mb-2">Analytics</h2>
|
||||
<p class="text-gray-500">Track your app usage stats.</p>
|
||||
</div>
|
||||
<!-- Student List Card -->
|
||||
<NuxtLink
|
||||
to="/students"
|
||||
class="flex-1 min-w-[250px] bg-white p-6 rounded-2xl shadow hover:shadow-md transition cursor-pointer hover:bg-blue-50"
|
||||
>
|
||||
<h2 class="text-xl font-semibold text-gray-700 mb-2">🎓 Students</h2>
|
||||
<p class="text-gray-500">Manage student records and attendance.</p>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
|
40
frontend/pages/students/AddClass.vue
Normal file
40
frontend/pages/students/AddClass.vue
Normal file
@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<h2 class="text-xl font-bold mb-4">Add New Class</h2>
|
||||
<form @submit.prevent="addClass" class="space-y-4">
|
||||
<div>
|
||||
<label class="block mb-1">Class Name</label>
|
||||
<input v-model="newClass.name" class="border p-2 w-full" required />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block mb-1">Section</label>
|
||||
<input v-model="newClass.section" class="border p-2 w-full" required />
|
||||
</div>
|
||||
<button type="submit" class="bg-blue-600 text-white px-4 py-2 rounded">Add Class</button>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useDirectus } from '../../app/composables/useDirectus'
|
||||
|
||||
const { createItem } = useDirectus()
|
||||
|
||||
const newClass = ref({
|
||||
name: '',
|
||||
section: '',
|
||||
})
|
||||
|
||||
const addClass = async () => {
|
||||
try {
|
||||
await createItem('classes', newClass.value)
|
||||
alert('Class added successfully!')
|
||||
newClass.value = { name: '', section: '' }
|
||||
} catch (err) {
|
||||
alert('Failed to add class.')
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
55
frontend/pages/students/[id].vue
Normal file
55
frontend/pages/students/[id].vue
Normal file
@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<NuxtLink to="/students" class="text-blue-600 hover:underline mb-4 inline-block">← Back to Students</NuxtLink>
|
||||
|
||||
<div v-if="loading" class="text-gray-500">Loading student details...</div>
|
||||
|
||||
<div v-else-if="student" class="bg-white shadow-md p-6 rounded-lg">
|
||||
<h2 class="text-2xl font-semibold mb-4">{{ student.name }}</h2>
|
||||
|
||||
<ul class="space-y-2 text-gray-700">
|
||||
<li><strong>Email:</strong> {{ student.email }}</li>
|
||||
<li><strong>Phone:</strong> {{ student.phone }}</li>
|
||||
<li><strong>Date of Birth:</strong> {{ student.dob }}</li>
|
||||
<li><strong>Gender:</strong> {{ student.gender }}</li>
|
||||
<li><strong>Class:</strong> {{ student.class_id?.name || 'N/A' }}</li>
|
||||
<li><strong>Section:</strong> {{ student.class_id?.section || 'N/A' }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-else class="text-red-600">
|
||||
Student not found.
|
||||
</div>
|
||||
<NuxtLink :to="`/students/${student.id}/edit`" class="text-blue-500 mt-4 block">✏️ Edit Student</NuxtLink>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useDirectus } from '../../app/composables/useDirectus'
|
||||
|
||||
const route = useRoute()
|
||||
const directus = useDirectus()
|
||||
|
||||
const student = ref(null)
|
||||
const loading = ref(true)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const { data } = await directus.items('students').readOne(route.params.id, {
|
||||
fields: ['id', 'name', 'email', 'phone', 'dob', 'gender', 'class_id.name', 'class_id.section']
|
||||
})
|
||||
student.value = data
|
||||
} catch (error) {
|
||||
console.error('Error fetching student:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
|
100
frontend/pages/students/add.vue
Normal file
100
frontend/pages/students/add.vue
Normal file
@ -0,0 +1,100 @@
|
||||
<template>
|
||||
<div class="p-6 max-w-2xl mx-auto">
|
||||
<h2 class="text-2xl font-bold mb-6">Add New Student</h2>
|
||||
|
||||
<form @submit.prevent="submitForm" class="space-y-4">
|
||||
<div>
|
||||
<label class="block font-medium">Name</label>
|
||||
<input v-model="form.name" type="text" required class="input" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block font-medium">Email</label>
|
||||
<input v-model="form.email" type="email" required class="input" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block font-medium">Phone</label>
|
||||
<input v-model="form.phone" type="text" required class="input" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block font-medium">Date of Birth</label>
|
||||
<input v-model="form.dob" type="date" required class="input" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block font-medium">Gender</label>
|
||||
<select v-model="form.gender" required class="input">
|
||||
<option value="">Select Gender</option>
|
||||
<option value="male">Male</option>
|
||||
<option value="female">Female</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block font-medium">Class</label>
|
||||
<select v-model="form.class_id" required class="input">
|
||||
<option value="">Select Class</option>
|
||||
<option v-for="cls in classes" :key="cls.id" :value="cls.id">
|
||||
{{ cls.name }} - {{ cls.section }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700">
|
||||
Submit
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useDirectus } from '../../app/composables/useDirectus'
|
||||
|
||||
const directus = useDirectus()
|
||||
const router = useRouter()
|
||||
|
||||
const form = ref({
|
||||
name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
dob: '',
|
||||
gender: '',
|
||||
class_id: ''
|
||||
})
|
||||
|
||||
const classes = ref([])
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const data = await directus.fetchCollection('classes')
|
||||
classes.value = data
|
||||
} catch (err) {
|
||||
console.error('Error loading classes:', err)
|
||||
}
|
||||
})
|
||||
|
||||
const submitForm = async () => {
|
||||
try {
|
||||
await directus.createItem('students', form.value)
|
||||
alert('Student added successfully!')
|
||||
router.push('/students')
|
||||
} catch (err) {
|
||||
console.error('Error adding student:', err)
|
||||
alert('Failed to add student.')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
<style scoped>
|
||||
.input {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
</style>
|
79
frontend/pages/students/attendance.vue
Normal file
79
frontend/pages/students/attendance.vue
Normal file
@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<h2 class="text-2xl font-bold mb-6">📆 Student Attendance Records</h2>
|
||||
|
||||
<div v-if="loading" class="text-gray-500">Loading attendance...</div>
|
||||
|
||||
<div v-else-if="records.length">
|
||||
<table class="min-w-full bg-white shadow-md rounded-lg overflow-hidden">
|
||||
<thead class="bg-gray-100">
|
||||
<tr>
|
||||
<th class="text-left px-4 py-2">#</th>
|
||||
<th class="text-left px-4 py-2">Student Name</th>
|
||||
<th class="text-left px-4 py-2">Class</th>
|
||||
<th class="text-left px-4 py-2">Date</th>
|
||||
<th class="text-left px-4 py-2">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(record, index) in records" :key="record.id" class="border-t">
|
||||
<td class="px-4 py-2">{{ index + 1 }}</td>
|
||||
<td class="px-4 py-2">{{ record.student_id?.name || '—' }}</td>
|
||||
<td class="px-4 py-2">{{ record.student_id?.class_id?.name || '—' }} {{ record.student_id?.class_id?.section }}</td>
|
||||
<td class="px-4 py-2">{{ record.date }}</td>
|
||||
<td class="px-4 py-2">
|
||||
<span
|
||||
:class="record.status === 'present' ? 'text-green-600 font-semibold' : 'text-red-600 font-semibold'"
|
||||
>
|
||||
{{ record.status }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-else class="text-gray-500 mt-4">No attendance records found.</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useDirectus } from '../../app/composables/useDirectus'
|
||||
|
||||
const directus = useDirectus()
|
||||
const records = ref([])
|
||||
const loading = ref(true)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const { data } = await directus.items('attendance').readByQuery({
|
||||
fields: [
|
||||
'id',
|
||||
'date',
|
||||
'status',
|
||||
'student_id.id',
|
||||
'student_id.name',
|
||||
'student_id.class_id.name',
|
||||
'student_id.class_id.section'
|
||||
],
|
||||
sort: ['-date']
|
||||
})
|
||||
records.value = data
|
||||
} catch (err) {
|
||||
console.error('Error fetching attendance:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
th, td {
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
</style>
|
||||
|
61
frontend/pages/students/classes.vue
Normal file
61
frontend/pages/students/classes.vue
Normal file
@ -0,0 +1,61 @@
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<h2 class="text-2xl font-bold mb-6">🏫 Classes List</h2>
|
||||
|
||||
<div v-if="loading" class="text-gray-500">Loading classes...</div>
|
||||
|
||||
<div v-else-if="classes.length">
|
||||
<table class="min-w-full bg-white shadow-md rounded-lg overflow-hidden">
|
||||
<thead class="bg-gray-100">
|
||||
<tr>
|
||||
<th class="text-left px-4 py-2">#</th>
|
||||
<th class="text-left px-4 py-2">Class Name</th>
|
||||
<th class="text-left px-4 py-2">Section</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(cls, index) in classes" :key="cls.id" class="border-t">
|
||||
<td class="px-4 py-2">{{ index + 1 }}</td>
|
||||
<td class="px-4 py-2">{{ cls.name }}</td>
|
||||
<td class="px-4 py-2">{{ cls.section }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-else class="text-gray-500 mt-4">No classes found.</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useDirectus } from '../../app/composables/useDirectus'
|
||||
|
||||
const directus = useDirectus()
|
||||
const classes = ref([])
|
||||
const loading = ref(true)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const { data } = await directus.items('classes').readByQuery({
|
||||
fields: ['id', 'name', 'section'],
|
||||
sort: ['name']
|
||||
})
|
||||
classes.value = data
|
||||
} catch (err) {
|
||||
console.error('Error loading classes:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
th, td {
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
</style>
|
||||
|
113
frontend/pages/students/edit.vue
Normal file
113
frontend/pages/students/edit.vue
Normal file
@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<div class="p-6 max-w-2xl mx-auto">
|
||||
<h2 class="text-2xl font-bold mb-6">✏️ Edit Student</h2>
|
||||
|
||||
<form @submit.prevent="submitForm" class="space-y-4" v-if="form">
|
||||
<div>
|
||||
<label class="block font-medium">Name</label>
|
||||
<input v-model="form.name" type="text" required class="input" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block font-medium">Email</label>
|
||||
<input v-model="form.email" type="email" required class="input" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block font-medium">Phone</label>
|
||||
<input v-model="form.phone" type="text" required class="input" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block font-medium">Date of Birth</label>
|
||||
<input v-model="form.dob" type="date" required class="input" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block font-medium">Gender</label>
|
||||
<select v-model="form.gender" required class="input">
|
||||
<option value="">Select Gender</option>
|
||||
<option value="male">Male</option>
|
||||
<option value="female">Female</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block font-medium">Class</label>
|
||||
<select v-model="form.class_id" required class="input">
|
||||
<option value="">Select Class</option>
|
||||
<option v-for="cls in classes" :key="cls.id" :value="cls.id">
|
||||
{{ cls.name }} - {{ cls.section }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="bg-green-600 text-white px-4 py-2 rounded hover:bg-green-700">
|
||||
Update Student
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div v-else class="text-gray-500">Loading student info...</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useDirectus } from '../../app/composables/useDirectus'
|
||||
|
||||
const directus = useDirectus()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const studentId = route.query.id
|
||||
const form = ref(null)
|
||||
const classes = ref([])
|
||||
|
||||
const fetchClasses = async () => {
|
||||
try {
|
||||
const { data } = await directus.items('classes').readByQuery({
|
||||
fields: ['id', 'name', 'section'],
|
||||
sort: ['name']
|
||||
})
|
||||
classes.value = data
|
||||
} catch (err) {
|
||||
console.error('Error loading classes:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchStudent = async () => {
|
||||
try {
|
||||
const student = await directus.items('students').readOne(studentId)
|
||||
form.value = student
|
||||
} catch (err) {
|
||||
console.error('Error loading student:', err)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchClasses()
|
||||
await fetchStudent()
|
||||
})
|
||||
|
||||
const submitForm = async () => {
|
||||
try {
|
||||
await directus.items('students').updateOne(studentId, form.value)
|
||||
alert('Student updated successfully!')
|
||||
router.push('/students')
|
||||
} catch (err) {
|
||||
console.error('Error updating student:', err)
|
||||
alert('Failed to update student.')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.input {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
</style>
|
||||
|
94
frontend/pages/students/index.vue
Normal file
94
frontend/pages/students/index.vue
Normal file
@ -0,0 +1,94 @@
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-2xl font-bold">👨🎓 Students List</h2>
|
||||
<NuxtLink to="/students/add" class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700">
|
||||
➕ Add Student
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="text-gray-500">Loading students...</div>
|
||||
|
||||
<div v-else-if="students.length">
|
||||
<table class="min-w-full bg-white shadow-md rounded overflow-hidden">
|
||||
<thead class="bg-gray-100">
|
||||
<tr>
|
||||
<th class="text-left px-4 py-2">#</th>
|
||||
<th class="text-left px-4 py-2">Name</th>
|
||||
<th class="text-left px-4 py-2">Email</th>
|
||||
<th class="text-left px-4 py-2">Class</th>
|
||||
<th class="text-left px-4 py-2">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(student, index) in students" :key="student.id" class="border-t">
|
||||
<td class="px-4 py-2">{{ index + 1 }}</td>
|
||||
<td class="px-4 py-2">{{ student.name }}</td>
|
||||
<td class="px-4 py-2">{{ student.email }}</td>
|
||||
<td class="px-4 py-2">
|
||||
{{ student.class_id?.name }} - {{ student.class_id?.section }}
|
||||
</td>
|
||||
<td class="px-4 py-2 flex gap-2">
|
||||
<NuxtLink :to="`/students?id=${student.id}`" class="text-sm text-blue-600 hover:underline">View</NuxtLink>
|
||||
|
||||
<NuxtLink :to="`/students/edit?id=${student.id}`" class="text-sm text-yellow-600 hover:underline">Edit
|
||||
</NuxtLink>
|
||||
|
||||
<button @click="deleteStudent(student.id)" class="text-sm text-red-600 hover:underline">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-else class="text-gray-500">No students found.</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useDirectus } from '../../app/composables/useDirectus'
|
||||
|
||||
const { fetchCollection, deleteItem } = useDirectus()
|
||||
const students = ref([])
|
||||
const loading = ref(true)
|
||||
|
||||
const fetchStudents = async () => {
|
||||
try {
|
||||
students.value = await fetchCollection('students', {
|
||||
fields: ['id', 'name', 'email', 'class_id.name', 'class_id.section'],
|
||||
sort: ['name']
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch students:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const deleteStudent = async (id) => {
|
||||
if (!confirm('Are you sure you want to delete this student?')) return
|
||||
|
||||
try {
|
||||
await deleteItem('students', id)
|
||||
students.value = students.value.filter((s) => s.id !== id)
|
||||
alert('Student deleted!')
|
||||
} catch (err) {
|
||||
console.error('Error deleting student:', err)
|
||||
alert('Failed to delete student.')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchStudents)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
</style>
|
55
frontend/pages/students/profile.vue
Normal file
55
frontend/pages/students/profile.vue
Normal file
@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<div class="p-6 max-w-3xl mx-auto">
|
||||
<h2 class="text-2xl font-bold mb-6">📄 Student Profile</h2>
|
||||
|
||||
<div v-if="student" class="bg-white p-6 rounded shadow-md space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div><strong>Name:</strong> {{ student.name }}</div>
|
||||
<div><strong>Email:</strong> {{ student.email }}</div>
|
||||
<div><strong>Phone:</strong> {{ student.phone }}</div>
|
||||
<div><strong>Date of Birth:</strong> {{ formatDate(student.dob) }}</div>
|
||||
<div><strong>Gender:</strong> {{ student.gender }}</div>
|
||||
<div>
|
||||
<strong>Class:</strong>
|
||||
{{ student.class_id?.name }} - {{ student.class_id?.section }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NuxtLink :to="`/students/edit?id=${student.id}`" class="inline-block mt-4 bg-yellow-500 text-white px-4 py-2 rounded hover:bg-yellow-600">
|
||||
✏️ Edit Profile
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div v-else class="text-gray-500">Loading student profile...</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useDirectus } from '../../app/composables/useDirectus'
|
||||
|
||||
const route = useRoute()
|
||||
const directus = useDirectus()
|
||||
const student = ref(null)
|
||||
|
||||
const fetchStudent = async () => {
|
||||
try {
|
||||
const studentId = route.query.id
|
||||
const data = await directus.items('students').readOne(studentId, {
|
||||
fields: ['*', 'class_id.name', 'class_id.section']
|
||||
})
|
||||
student.value = data
|
||||
} catch (err) {
|
||||
console.error('Failed to load student profile:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return '-'
|
||||
return new Date(dateStr).toLocaleDateString()
|
||||
}
|
||||
|
||||
onMounted(fetchStudent)
|
||||
</script>
|
||||
|
75
frontend/pages/students/settings.vue
Normal file
75
frontend/pages/students/settings.vue
Normal file
@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<div class="p-6 max-w-2xl mx-auto">
|
||||
<h2 class="text-2xl font-bold mb-6">⚙️ Student Settings</h2>
|
||||
|
||||
<form @submit.prevent="saveSettings" class="space-y-6 bg-white p-6 rounded shadow-md">
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">Default Class for New Students</label>
|
||||
<select v-model="settings.defaultClass" class="w-full border px-4 py-2 rounded">
|
||||
<option v-for="cls in classes" :key="cls.id" :value="cls.id">
|
||||
{{ cls.name }} - {{ cls.section }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">Mark Absent After</label>
|
||||
<input
|
||||
type="number"
|
||||
v-model="settings.absentAfter"
|
||||
min="1"
|
||||
class="w-full border px-4 py-2 rounded"
|
||||
placeholder="e.g. 3 days"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 mt-1">Auto-mark absent if not present for X consecutive days.</p>
|
||||
</div>
|
||||
|
||||
<div class="pt-4">
|
||||
<button type="submit" class="bg-green-600 text-white px-4 py-2 rounded hover:bg-green-700">
|
||||
💾 Save Settings
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useDirectus } from '../../app/composables/useDirectus'
|
||||
|
||||
const directus = useDirectus()
|
||||
const settings = ref({
|
||||
defaultClass: '',
|
||||
absentAfter: 3,
|
||||
})
|
||||
const classes = ref([])
|
||||
|
||||
const fetchClasses = async () => {
|
||||
try {
|
||||
const { data } = await directus.items('classes').readByQuery({
|
||||
fields: ['id', 'name', 'section'],
|
||||
sort: ['name']
|
||||
})
|
||||
classes.value = data
|
||||
} catch (err) {
|
||||
console.error('Error loading classes:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchSettings = async () => {
|
||||
// Load settings from a custom 'student_settings' collection or localStorage fallback
|
||||
const saved = JSON.parse(localStorage.getItem('student_settings') || '{}')
|
||||
settings.value = { ...settings.value, ...saved }
|
||||
}
|
||||
|
||||
const saveSettings = () => {
|
||||
localStorage.setItem('student_settings', JSON.stringify(settings.value))
|
||||
alert('✅ Settings saved successfully!')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchSettings()
|
||||
fetchClasses()
|
||||
})
|
||||
</script>
|
||||
|
Loading…
Reference in New Issue
Block a user