successfully working CRUD with directus

This commit is contained in:
ATUL GUNJAL 2025-04-21 14:24:21 +05:30
parent 24a979b364
commit 32830a9dd2
28 changed files with 1910 additions and 59 deletions

View File

@ -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">
&copy; 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">
&copy; 2025 Atul Gunjal
</div>
</aside>
</template>

View 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 todays 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
}
}

View 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()
})
})
})

View 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:

View File

@ -8,4 +8,14 @@ export default defineNuxtConfig({
tailwindcss(),
],
},
// proxy api
nitro: {
devProxy: {
'/api/directus': {
target: 'http://localhost:8055',
changeOrigin: true,
},
},
},
});

View File

@ -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",

View File

@ -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",

View File

@ -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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>