diff --git a/.gitignore b/.gitignore index a899defcc..37474144b 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,6 @@ yarn-error.log* src/SPREADSHEET_SECRETS.js .firebase/ public/precache-manifest.* + +# Project management (internal) +.project/ diff --git a/README.md b/README.md index b3e4038fb..2fa514731 100644 --- a/README.md +++ b/README.md @@ -23,10 +23,19 @@ To learn, check out the [How to Create Pull Requests](https://help.github.com/en 2. npm run firebase:local 3. npm run localdev -`npm run firebase:local` will configure and start the Firebase emulator suite. -On your first run, you will need to open the emulator suite and add yourself as a user. +`npm run firebase:local` will configure and start the Firebase emulator suite. +On your first run, you will need to open the emulator suite and add yourself as a user. Any data you modify will be saved locally for future use. +### `Seed Test Data` +To populate the local emulators with test data: +```bash +node scripts/seed-full.js +``` +This creates users for all roles (admin, programLeader, siteLeader, coach, teacher) and sample data. + +**Test credentials:** `admin@chalk.local` / `admin123` + In the project directory, you can run: ### `npm run livedev` @@ -77,6 +86,21 @@ Launches the test runner in the Cypress Cloud Dashboar 1. Instead of helloGET substitute with function Folder name 2. CI/CD Is not done for Cloud Functions so deploy and submit pull request. +## Admin Features + +### All Users Dashboard +Available to Admin, Program Leader, and Site Leader roles via the navigation menu. + +**Features:** +- View all users with search and filters (role, status) +- Sort by any column (name, email, role, school, status, last login) +- Edit user name and email +- Archive/unarchive users +- Export to Excel +- Pagination (10/25/50 per page) + +**Route:** `/AllUsers` + ## Learn More JSDocumentation is Available at [Chalk Docs](https://chalkdocs.web.app). You can learn more about webpack at [Webpack](https://webpack.js.org/). diff --git a/cypress/integration/admin/all-users.ts b/cypress/integration/admin/all-users.ts new file mode 100644 index 000000000..f4846109a --- /dev/null +++ b/cypress/integration/admin/all-users.ts @@ -0,0 +1,119 @@ +describe('All Users Dashboard', () => { + beforeEach(() => { + cy.visit('/') + }) + + it('Navigates to All Users page as admin', () => { + // Login as admin + cy.get('input[type="email"]').type('admin@chalk.local') + cy.get('input[type="password"]').type('admin123') + cy.get('button[type="submit"]').click() + + // Wait for home page to load + cy.url().should('include', '/Home', { timeout: 15000 }) + + // Open burger menu and click All Users + cy.get('[aria-label="menu"]').first().click() + cy.contains('All Users').click() + + // Verify All Users page loads + cy.url().should('include', '/AllUsers') + cy.contains('All Users', { timeout: 10000 }) + }) + + it('Displays users table with data', () => { + // Login + cy.get('input[type="email"]').type('admin@chalk.local') + cy.get('input[type="password"]').type('admin123') + cy.get('button[type="submit"]').click() + cy.url().should('include', '/Home', { timeout: 15000 }) + + // Navigate to All Users + cy.visit('/AllUsers') + + // Verify table headers exist + cy.contains('Last Name', { timeout: 10000 }) + cy.contains('First Name') + cy.contains('Email') + cy.contains('Role') + cy.contains('Status') + cy.contains('Last Login') + }) + + it('Search filters users correctly', () => { + cy.get('input[type="email"]').type('admin@chalk.local') + cy.get('input[type="password"]').type('admin123') + cy.get('button[type="submit"]').click() + cy.url().should('include', '/Home', { timeout: 15000 }) + + cy.visit('/AllUsers') + cy.contains('Last Name', { timeout: 10000 }) + + // Search for a specific user + cy.get('input[label="Search"]').type('Admin') + cy.contains('Admin User') + }) + + it('Role filter works correctly', () => { + cy.get('input[type="email"]').type('admin@chalk.local') + cy.get('input[type="password"]').type('admin123') + cy.get('button[type="submit"]').click() + cy.url().should('include', '/Home', { timeout: 15000 }) + + cy.visit('/AllUsers') + cy.contains('Last Name', { timeout: 10000 }) + + // Filter by Coach role + cy.get('#mui-component-select-Role, [aria-labelledby*="Role"]').click() + cy.contains('Coach').click() + + // Verify only coaches are shown + cy.get('table tbody tr').each(($row) => { + cy.wrap($row).contains('Coach') + }) + }) + + it('Opens edit dialog when clicking a user', () => { + cy.get('input[type="email"]').type('admin@chalk.local') + cy.get('input[type="password"]').type('admin123') + cy.get('button[type="submit"]').click() + cy.url().should('include', '/Home', { timeout: 15000 }) + + cy.visit('/AllUsers') + cy.contains('Last Name', { timeout: 10000 }) + + // Click on a user row + cy.get('table tbody tr').first().click() + + // Verify edit dialog opens + cy.contains('Edit User') + cy.get('input[label="First Name"], input[value]').should('exist') + }) + + it('Export button exists', () => { + cy.get('input[type="email"]').type('admin@chalk.local') + cy.get('input[type="password"]').type('admin123') + cy.get('button[type="submit"]').click() + cy.url().should('include', '/Home', { timeout: 15000 }) + + cy.visit('/AllUsers') + cy.contains('Last Name', { timeout: 10000 }) + + // Verify export button exists + cy.contains('Export') + }) + + it('Pagination controls work', () => { + cy.get('input[type="email"]').type('admin@chalk.local') + cy.get('input[type="password"]').type('admin123') + cy.get('button[type="submit"]').click() + cy.url().should('include', '/Home', { timeout: 15000 }) + + cy.visit('/AllUsers') + cy.contains('Last Name', { timeout: 10000 }) + + // Verify pagination exists + cy.contains('Rows per page') + cy.get('[aria-label="Go to next page"], [title="Go to next page"]').should('exist') + }) +}) diff --git a/functions/package-lock.json b/functions/package-lock.json index 3e237137b..d2b36a8b6 100644 --- a/functions/package-lock.json +++ b/functions/package-lock.json @@ -17,7 +17,7 @@ "firebase-functions-test": "^0.2.0" }, "engines": { - "node": "10" + "node": "20" } }, "node_modules/@babel/parser": { diff --git a/scripts/seed-full.js b/scripts/seed-full.js new file mode 100644 index 000000000..65e1c471b --- /dev/null +++ b/scripts/seed-full.js @@ -0,0 +1,872 @@ +/** + * Full Seed Script for CHALK Coaching App + * Populates all Firestore collections with realistic test data + * Run with: node scripts/seed-full.js + */ + +const firebase = require('firebase'); + +// Firebase config from .env-cmdrc.js (development) - required to initialize SDK before connecting to emulators +const config = { + apiKey: "AIzaSyAdl6szTzbtdD3iq8VoS86ZsMWSxUFtaJ4", + authDomain: "chalk-dev-c6a5d.firebaseapp.com", + projectId: "chalk-dev-c6a5d" +}; + +firebase.initializeApp(config); + +// Connect to emulators +firebase.auth().useEmulator("http://localhost:9099"); +firebase.firestore().useEmulator("localhost", 8080); + +const auth = firebase.auth(); +const db = firebase.firestore(); + +// Helper to create Firestore timestamp +const timestamp = (daysAgo = 0) => { + const date = new Date(); + date.setDate(date.getDate() - daysAgo); + return firebase.firestore.Timestamp.fromDate(date); +}; + +// ============================================ +// DATA DEFINITIONS +// ============================================ + +const sites = [ + { id: 'site-lincoln', name: 'Lincoln Elementary' }, + { id: 'site-washington', name: 'Washington Elementary' }, + { id: 'site-jefferson', name: 'Jefferson Preschool' }, + { id: 'site-roosevelt', name: 'Roosevelt Early Learning Center' } +]; + +const programs = [ + { + id: 'program-metro', + name: 'Metro Early Learning Initiative', + sites: ['site-lincoln', 'site-washington'], + leaders: [] + }, + { + id: 'program-rural', + name: 'Rural Schools Partnership', + sites: ['site-jefferson', 'site-roosevelt'], + leaders: [] + } +]; + +const users = [ + // Admin + { + email: "admin@chalk.local", + password: "admin123", + data: { + firstName: "Admin", + lastName: "User", + role: "admin", + school: "CHALK Admin Office", + phone: "555-0001", + notes: "", + archived: false, + programs: [], + sites: [], + favouriteQuestions: [], + playedVideos: [], + unlocked: [1, 2, 3, 4, 5, 6, 7, 8, 9] + } + }, + // Program Leaders + { + email: "leader1@chalk.local", + password: "leader123", + data: { + firstName: "Patricia", + lastName: "Martinez", + role: "programLeader", + school: "District Office", + phone: "555-0002", + notes: "Metro program coordinator", + archived: false, + programs: ['program-metro'], + sites: [], + favouriteQuestions: [], + playedVideos: [], + unlocked: [1, 2, 3, 4, 5, 6, 7, 8, 9] + } + }, + { + email: "leader2@chalk.local", + password: "leader123", + data: { + firstName: "Robert", + lastName: "Thompson", + role: "programLeader", + school: "Rural District Office", + phone: "555-0003", + notes: "Rural program coordinator", + archived: false, + programs: ['program-rural'], + sites: [], + favouriteQuestions: [], + playedVideos: [], + unlocked: [1, 2, 3, 4, 5, 6, 7, 8, 9] + } + }, + // Site Leaders + { + email: "siteleader1@chalk.local", + password: "site123", + data: { + firstName: "Jennifer", + lastName: "Wilson", + role: "siteLeader", + school: "Lincoln Elementary", + phone: "555-0010", + notes: "Lincoln site lead", + archived: false, + programs: [], + sites: ['site-lincoln'], + favouriteQuestions: [], + playedVideos: [], + unlocked: [1, 2, 3, 4, 5] + } + }, + { + email: "siteleader2@chalk.local", + password: "site123", + data: { + firstName: "David", + lastName: "Anderson", + role: "siteLeader", + school: "Washington Elementary", + phone: "555-0011", + notes: "Washington site lead", + archived: false, + programs: [], + sites: ['site-washington'], + favouriteQuestions: [], + playedVideos: [], + unlocked: [1, 2, 3, 4, 5] + } + }, + // Coaches + { + email: "coach1@chalk.local", + password: "coach123", + data: { + firstName: "Maria", + lastName: "Garcia", + role: "coach", + school: "Lincoln Elementary", + phone: "555-0020", + notes: "Experienced literacy coach", + archived: false, + programs: [], + sites: ['site-lincoln'], + favouriteQuestions: ['What strategies worked well today?', 'How did the children respond?'], + playedVideos: ['video-1', 'video-2'], + unlocked: [1, 2, 3, 4, 5, 6, 7, 8, 9] + } + }, + { + email: "coach2@chalk.local", + password: "coach123", + data: { + firstName: "John", + lastName: "Smith", + role: "coach", + school: "Washington Elementary", + phone: "555-0021", + notes: "Math instruction specialist", + archived: false, + programs: [], + sites: ['site-washington'], + favouriteQuestions: ['What math concepts did you cover?'], + playedVideos: ['video-3'], + unlocked: [1, 2, 3, 4, 5, 6] + } + }, + { + email: "coach3@chalk.local", + password: "coach123", + data: { + firstName: "Lisa", + lastName: "Chen", + role: "coach", + school: "Jefferson Preschool", + phone: "555-0022", + notes: "Early childhood specialist", + archived: false, + programs: [], + sites: ['site-jefferson'], + favouriteQuestions: [], + playedVideos: [], + unlocked: [1, 2, 3] + } + }, + // Teachers + { + email: "teacher1@chalk.local", + password: "teacher123", + data: { + firstName: "Sarah", + lastName: "Johnson", + role: "teacher", + school: "Lincoln Elementary", + phone: "555-0030", + notes: "Pre-K teacher, Room 101", + archived: false, + programs: [], + sites: ['site-lincoln'], + favouriteQuestions: [], + playedVideos: [], + unlocked: [] + } + }, + { + email: "teacher2@chalk.local", + password: "teacher123", + data: { + firstName: "Michael", + lastName: "Brown", + role: "teacher", + school: "Lincoln Elementary", + phone: "555-0031", + notes: "Kindergarten teacher, Room 102", + archived: false, + programs: [], + sites: ['site-lincoln'], + favouriteQuestions: [], + playedVideos: [], + unlocked: [] + } + }, + { + email: "teacher3@chalk.local", + password: "teacher123", + data: { + firstName: "Emily", + lastName: "Davis", + role: "teacher", + school: "Washington Elementary", + phone: "555-0032", + notes: "Pre-K teacher, Room 201", + archived: false, + programs: [], + sites: ['site-washington'], + favouriteQuestions: [], + playedVideos: [], + unlocked: [] + } + }, + { + email: "teacher4@chalk.local", + password: "teacher123", + data: { + firstName: "James", + lastName: "Miller", + role: "teacher", + school: "Washington Elementary", + phone: "555-0033", + notes: "Kindergarten teacher, Room 202", + archived: false, + programs: [], + sites: ['site-washington'], + favouriteQuestions: [], + playedVideos: [], + unlocked: [] + } + }, + { + email: "teacher5@chalk.local", + password: "teacher123", + data: { + firstName: "Amanda", + lastName: "Taylor", + role: "teacher", + school: "Jefferson Preschool", + phone: "555-0034", + notes: "Toddler room teacher", + archived: false, + programs: [], + sites: ['site-jefferson'], + favouriteQuestions: [], + playedVideos: [], + unlocked: [] + } + }, + { + email: "teacher6@chalk.local", + password: "teacher123", + data: { + firstName: "Christopher", + lastName: "White", + role: "teacher", + school: "Roosevelt Early Learning Center", + phone: "555-0035", + notes: "3-year-old classroom", + archived: true, // Archived teacher for testing + programs: [], + sites: ['site-roosevelt'], + favouriteQuestions: [], + playedVideos: [], + unlocked: [] + } + } +]; + +// Observation types with their tool codes +const observationTypes = [ + { code: 'TT', name: 'Transition Time' }, + { code: 'CC', name: 'Classroom Climate' }, + { code: 'MI', name: 'Math Instruction' }, + { code: 'SE', name: 'Student Engagement' }, + { code: 'IN', name: 'Level of Instruction' }, + { code: 'LC', name: 'Listening to Children' }, + { code: 'SA', name: 'Sequential Activities' }, + { code: 'LI', name: 'Literacy Instruction' }, + { code: 'AC', name: 'Associative Cooperative' } +]; + +// Store created user IDs +const userIds = {}; + +// ============================================ +// SEED FUNCTIONS +// ============================================ + +async function seedSites(adminUid) { + console.log('\n--- Seeding Sites ---'); + for (const site of sites) { + await db.collection('sites').doc(site.id).set({ + id: site.id, + name: site.name, + programs: [] + }); + console.log(`Created site: ${site.name}`); + } +} + +async function seedPrograms() { + console.log('\n--- Seeding Programs ---'); + for (const program of programs) { + await db.collection('programs').doc(program.id).set({ + id: program.id, + name: program.name, + sites: program.sites, + leaders: program.leaders + }); + + // Update sites with program reference + for (const siteId of program.sites) { + await db.collection('sites').doc(siteId).update({ + programs: firebase.firestore.FieldValue.arrayUnion(program.id) + }); + } + console.log(`Created program: ${program.name}`); + } +} + +async function seedUsersExceptAdmin() { + console.log('\n--- Seeding Users ---'); + + // Filter out admin since already created + const nonAdminUsers = users.filter(u => u.data.role !== 'admin'); + + for (const user of nonAdminUsers) { + try { + // Create auth user + const userCredential = await auth.createUserWithEmailAndPassword(user.email, user.password); + const uid = userCredential.user.uid; + userIds[user.email] = uid; + + // Create Firestore document + await db.collection('users').doc(uid).set({ + ...user.data, + id: uid, + email: user.email + }); + console.log(`Created user: ${user.data.firstName} ${user.data.lastName} (${user.data.role})`); + + await auth.signOut(); + } catch (error) { + console.error(`Error creating ${user.email}:`, error.message); + } + } + + // Sign in as admin to create Practice Teacher and update programs + await auth.signInWithEmailAndPassword('admin@chalk.local', 'admin123'); + + // Create Practice Teacher (special ID) + await db.collection('users').doc('rJxNhJmzjRZP7xg29Ko6').set({ + id: 'rJxNhJmzjRZP7xg29Ko6', + firstName: 'Practice', + lastName: 'Teacher', + email: 'practice@teacher.edu', + role: 'teacher', + school: 'Elum Entaree School', + phone: '012-345-6789', + notes: 'Practice teacher for new coaches', + archived: false, + programs: [], + sites: [], + favouriteQuestions: [], + playedVideos: [], + unlocked: [] + }); + console.log('Created Practice Teacher'); + + // Update program leaders + await db.collection('programs').doc('program-metro').update({ + leaders: [userIds['leader1@chalk.local']] + }); + await db.collection('programs').doc('program-rural').update({ + leaders: [userIds['leader2@chalk.local']] + }); + + await auth.signOut(); +} + +async function seedPartners() { + console.log('\n--- Seeding Coach-Teacher Partnerships ---'); + + // Login as admin to have write access + await auth.signInWithEmailAndPassword('admin@chalk.local', 'admin123'); + + // Coach 1 (Maria) -> Teachers 1 & 2 (Lincoln) + const coach1Id = userIds['coach1@chalk.local']; + const teacher1Id = userIds['teacher1@chalk.local']; + const teacher2Id = userIds['teacher2@chalk.local']; + + await db.collection('users').doc(coach1Id).collection('partners').doc(teacher1Id).set({}); + await db.collection('users').doc(coach1Id).collection('partners').doc(teacher2Id).set({}); + await db.collection('users').doc(coach1Id).collection('partners').doc('rJxNhJmzjRZP7xg29Ko6').set({}); + console.log('Coach Maria Garcia assigned to: Sarah Johnson, Michael Brown, Practice Teacher'); + + // Coach 2 (John) -> Teachers 3 & 4 (Washington) + const coach2Id = userIds['coach2@chalk.local']; + const teacher3Id = userIds['teacher3@chalk.local']; + const teacher4Id = userIds['teacher4@chalk.local']; + + await db.collection('users').doc(coach2Id).collection('partners').doc(teacher3Id).set({}); + await db.collection('users').doc(coach2Id).collection('partners').doc(teacher4Id).set({}); + await db.collection('users').doc(coach2Id).collection('partners').doc('rJxNhJmzjRZP7xg29Ko6').set({}); + console.log('Coach John Smith assigned to: Emily Davis, James Miller, Practice Teacher'); + + // Coach 3 (Lisa) -> Teacher 5 (Jefferson) + const coach3Id = userIds['coach3@chalk.local']; + const teacher5Id = userIds['teacher5@chalk.local']; + + await db.collection('users').doc(coach3Id).collection('partners').doc(teacher5Id).set({}); + await db.collection('users').doc(coach3Id).collection('partners').doc('rJxNhJmzjRZP7xg29Ko6').set({}); + console.log('Coach Lisa Chen assigned to: Amanda Taylor, Practice Teacher'); + + await auth.signOut(); +} + +async function seedObservations() { + console.log('\n--- Seeding Observations ---'); + + await auth.signInWithEmailAndPassword('admin@chalk.local', 'admin123'); + + const coach1Id = userIds['coach1@chalk.local']; + const teacher1Id = userIds['teacher1@chalk.local']; + const teacher2Id = userIds['teacher2@chalk.local']; + const coach2Id = userIds['coach2@chalk.local']; + const teacher3Id = userIds['teacher3@chalk.local']; + + const observations = [ + // Coach 1 observations + { coach: coach1Id, teacher: teacher1Id, type: 'TT', daysAgo: 2, completed: true }, + { coach: coach1Id, teacher: teacher1Id, type: 'CC', daysAgo: 5, completed: true }, + { coach: coach1Id, teacher: teacher1Id, type: 'MI', daysAgo: 10, completed: true }, + { coach: coach1Id, teacher: teacher2Id, type: 'SE', daysAgo: 3, completed: true }, + { coach: coach1Id, teacher: teacher2Id, type: 'IN', daysAgo: 7, completed: true }, + { coach: coach1Id, teacher: teacher1Id, type: 'LC', daysAgo: 14, completed: true }, + // Coach 2 observations + { coach: coach2Id, teacher: teacher3Id, type: 'SA', daysAgo: 1, completed: true }, + { coach: coach2Id, teacher: teacher3Id, type: 'LI', daysAgo: 4, completed: true }, + { coach: coach2Id, teacher: teacher3Id, type: 'AC', daysAgo: 8, completed: true }, + // Incomplete observation + { coach: coach1Id, teacher: teacher1Id, type: 'TT', daysAgo: 0, completed: false } + ]; + + for (const obs of observations) { + const startDate = new Date(); + startDate.setDate(startDate.getDate() - obs.daysAgo); + startDate.setHours(9, 0, 0, 0); + + const endDate = new Date(startDate); + endDate.setMinutes(endDate.getMinutes() + 20); + + const obsRef = db.collection('observations').doc(); + await obsRef.set({ + id: obsRef.id, + observedBy: `/user/${obs.coach}`, + teacher: `/user/${obs.teacher}`, + type: obs.type, + start: firebase.firestore.Timestamp.fromDate(startDate), + end: firebase.firestore.Timestamp.fromDate(endDate), + completed: obs.completed, + timezone: 'America/Chicago', + activitySetting: null, + checklist: null, + lastClickTime: firebase.firestore.Timestamp.fromDate(endDate), + timedOut: false + }); + + // Add some entries + for (let i = 0; i < 5; i++) { + const entryTime = new Date(startDate); + entryTime.setMinutes(entryTime.getMinutes() + (i * 4)); + + await obsRef.collection('entries').add({ + Timestamp: firebase.firestore.Timestamp.fromDate(entryTime), + value: Math.floor(Math.random() * 5) + 1 + }); + } + + // Add a note + await obsRef.collection('notes').add({ + Note: 'Good engagement observed during this activity.', + Timestamp: firebase.firestore.Timestamp.fromDate(startDate) + }); + + const typeName = observationTypes.find(t => t.code === obs.type)?.name || obs.type; + console.log(`Created ${obs.completed ? 'completed' : 'in-progress'} observation: ${typeName}`); + } + + await auth.signOut(); +} + +async function seedActionPlans() { + console.log('\n--- Seeding Action Plans ---'); + + await auth.signInWithEmailAndPassword('admin@chalk.local', 'admin123'); + + const coach1Id = userIds['coach1@chalk.local']; + const teacher1Id = userIds['teacher1@chalk.local']; + const teacher2Id = userIds['teacher2@chalk.local']; + + const actionPlans = [ + { + coach: coach1Id, + teacher: teacher1Id, + tool: 'TT', + goal: 'Reduce transition time between activities to under 3 minutes', + goalTimeline: '4 weeks', + benefit: 'More instructional time and smoother classroom flow', + status: 'Active', + daysAgo: 7 + }, + { + coach: coach1Id, + teacher: teacher1Id, + tool: 'CC', + goal: 'Increase positive behavior reinforcement', + goalTimeline: '6 weeks', + benefit: 'Better classroom climate and student engagement', + status: 'Active', + daysAgo: 14 + }, + { + coach: coach1Id, + teacher: teacher2Id, + tool: 'SE', + goal: 'Implement student engagement strategies during circle time', + goalTimeline: '3 weeks', + benefit: 'Higher participation rates', + status: 'Maintenance', + daysAgo: 30 + } + ]; + + for (let i = 0; i < actionPlans.length; i++) { + const plan = actionPlans[i]; + const planRef = db.collection('actionPlans').doc(); + + const createdDate = new Date(); + createdDate.setDate(createdDate.getDate() - plan.daysAgo); + + await planRef.set({ + id: planRef.id, + coach: plan.coach, + teacher: plan.teacher, + tool: plan.tool, + goal: plan.goal, + goalTimeline: plan.goalTimeline, + benefit: plan.benefit, + status: plan.status, + planNum: i + 1, + dateCreated: firebase.firestore.Timestamp.fromDate(createdDate), + dateModified: firebase.firestore.Timestamp.fromDate(new Date()) + }); + + // Add action steps + const steps = [ + { step: 'Review current transition procedures', person: 'Teacher' }, + { step: 'Introduce visual timer', person: 'Coach' }, + { step: 'Practice new routine with students', person: 'Teacher' }, + { step: 'Observe and provide feedback', person: 'Coach' } + ]; + + for (let j = 0; j < steps.length; j++) { + await planRef.collection('actionSteps').doc(j.toString()).set({ + step: steps[j].step, + person: steps[j].person, + timeline: null + }); + } + + console.log(`Created action plan: ${plan.goal.substring(0, 40)}...`); + } + + await auth.signOut(); +} + +async function seedConferencePlans() { + console.log('\n--- Seeding Conference Plans ---'); + + await auth.signInWithEmailAndPassword('admin@chalk.local', 'admin123'); + + const coach1Id = userIds['coach1@chalk.local']; + const teacher1Id = userIds['teacher1@chalk.local']; + + // Get an observation to link + const obsSnapshot = await db.collection('observations') + .where('observedBy', '==', `/user/${coach1Id}`) + .where('completed', '==', true) + .limit(1) + .get(); + + if (!obsSnapshot.empty) { + const obsId = obsSnapshot.docs[0].id; + + const confRef = db.collection('conferencePlans').doc(); + await confRef.set({ + id: confRef.id, + sessionId: obsId, + coach: coach1Id, + teacher: teacher1Id, + tool: 'TT', + dateCreated: timestamp(5), + dateModified: timestamp(0), + feedback: [ + 'Great job using the visual timer today!', + 'Students responded well to the countdown.' + ], + questions: [ + 'How did you feel about the transition?', + 'What would you do differently next time?' + ], + addedQuestions: [], + notes: ['Consider adding a transition song'] + }); + console.log('Created conference plan'); + } + + await auth.signOut(); +} + +async function seedAppointments() { + console.log('\n--- Seeding Appointments ---'); + + await auth.signInWithEmailAndPassword('admin@chalk.local', 'admin123'); + + const coach1Id = userIds['coach1@chalk.local']; + const coach2Id = userIds['coach2@chalk.local']; + const teacher1Id = userIds['teacher1@chalk.local']; + const teacher3Id = userIds['teacher3@chalk.local']; + + const appointments = [ + // Upcoming appointments + { coach: coach1Id, teacher: teacher1Id, tool: 'MI', daysFromNow: 2, completed: false }, + { coach: coach1Id, teacher: teacher1Id, tool: 'SE', daysFromNow: 5, completed: false }, + { coach: coach2Id, teacher: teacher3Id, tool: 'LC', daysFromNow: 3, completed: false }, + // Past appointments + { coach: coach1Id, teacher: teacher1Id, tool: 'TT', daysFromNow: -3, completed: true }, + { coach: coach1Id, teacher: teacher1Id, tool: 'CC', daysFromNow: -7, completed: true } + ]; + + for (const apt of appointments) { + const aptDate = new Date(); + aptDate.setDate(aptDate.getDate() + apt.daysFromNow); + aptDate.setHours(10, 0, 0, 0); + + await db.collection('appointments').add({ + coach: apt.coach, + teacherID: apt.teacher, + tool: apt.tool, + type: 'observation', + date: firebase.firestore.Timestamp.fromDate(aptDate), + completed: apt.completed, + removed: false + }); + + const typeName = observationTypes.find(t => t.code === apt.tool)?.name || apt.tool; + console.log(`Created appointment: ${typeName} (${apt.daysFromNow >= 0 ? 'upcoming' : 'past'})`); + } + + await auth.signOut(); +} + +async function seedEmails() { + console.log('\n--- Seeding Email Drafts ---'); + + await auth.signInWithEmailAndPassword('admin@chalk.local', 'admin123'); + + const coach1Id = userIds['coach1@chalk.local']; + const teacher1Id = userIds['teacher1@chalk.local']; + + const emails = [ + { + from: 'coach1@chalk.local', + to: 'teacher1@chalk.local', + subject: 'Follow-up from our Transition Time observation', + emailContent: 'Hi Sarah,\n\nThank you for allowing me to observe your classroom today...', + type: 'draft', + user: coach1Id, + recipientId: teacher1Id, + recipientFirstName: 'Sarah', + recipientName: 'Sarah Johnson', + recipientEmail: 'teacher1@chalk.local' + }, + { + from: 'coach1@chalk.local', + to: 'teacher1@chalk.local', + subject: 'Great progress on classroom climate!', + emailContent: 'Hi Sarah,\n\nI wanted to share some positive observations from this week...', + type: 'sent', + user: coach1Id, + recipientId: teacher1Id, + recipientFirstName: 'Sarah', + recipientName: 'Sarah Johnson', + recipientEmail: 'teacher1@chalk.local' + } + ]; + + for (const email of emails) { + const emailRef = db.collection('emails').doc(); + await emailRef.set({ + id: emailRef.id, + ...email, + dateCreated: timestamp(3), + dateModified: timestamp(0) + }); + console.log(`Created email (${email.type}): ${email.subject}`); + } + + await auth.signOut(); +} + +async function seedTrainingProgress() { + console.log('\n--- Seeding Training Progress ---'); + + await auth.signInWithEmailAndPassword('admin@chalk.local', 'admin123'); + + const coach1Id = userIds['coach1@chalk.local']; + + // Literacy training progress + await db.collection('users').doc(coach1Id).collection('training').doc('LI').set({ + conceptsFoundational: true, + conceptsWriting: true, + conceptsReading: true, + conceptsLanguage: false, + definitionsFoundational: true, + definitionsWriting: true, + definitionsReading: false, + definitionsLanguage: false, + demoFoundational: true, + demoWriting: false, + demoReading: false, + demoLanguage: false, + knowledgeCheckFoundational: true, + knowledgeCheckWriting: false, + knowledgeCheckReading: false, + knowledgeCheckLanguage: false + }); + console.log('Created training progress for Coach 1'); + + // Knowledge check responses + const responses = [ + { type: 'foundational', questionIndex: 0, answerIndex: 2, isCorrect: true }, + { type: 'foundational', questionIndex: 1, answerIndex: 1, isCorrect: true }, + { type: 'foundational', questionIndex: 2, answerIndex: 0, isCorrect: false }, + { type: 'writing', questionIndex: 0, answerIndex: 3, isCorrect: true } + ]; + + for (const resp of responses) { + await db.collection('knowledgeChecks').add({ + timestamp: timestamp(Math.floor(Math.random() * 14)), + answeredBy: coach1Id, + type: resp.type, + questionIndex: resp.questionIndex, + answerIndex: resp.answerIndex, + isCorrect: resp.isCorrect + }); + } + console.log('Created knowledge check responses'); + + await auth.signOut(); +} + +// ============================================ +// MAIN SEED FUNCTION +// ============================================ + +async function seed() { + console.log('='.repeat(50)); + console.log('CHALK Coaching - Full Database Seed'); + console.log('='.repeat(50)); + + try { + // First create admin user to have authentication for writes + console.log('\n--- Creating Admin User First ---'); + const adminUser = users.find(u => u.data.role === 'admin'); + const adminCred = await auth.createUserWithEmailAndPassword(adminUser.email, adminUser.password); + const adminUid = adminCred.user.uid; + userIds[adminUser.email] = adminUid; + + await db.collection('users').doc(adminUid).set({ + ...adminUser.data, + id: adminUid, + email: adminUser.email + }); + console.log(`Created admin: ${adminUser.data.firstName} ${adminUser.data.lastName}`); + + // Now we're signed in as admin - continue with other seeds + await seedSites(adminUid); + await seedPrograms(); + + // Sign out before creating other users + await auth.signOut(); + await seedUsersExceptAdmin(); + + await seedPartners(); + await seedObservations(); + await seedActionPlans(); + await seedConferencePlans(); + await seedAppointments(); + await seedEmails(); + await seedTrainingProgress(); + + console.log('\n' + '='.repeat(50)); + console.log('SEED COMPLETE!'); + console.log('='.repeat(50)); + console.log('\nTest Accounts:'); + console.log(' Admin: admin@chalk.local / admin123'); + console.log(' Program Leader: leader1@chalk.local / leader123'); + console.log(' Site Leader: siteleader1@chalk.local / site123'); + console.log(' Coach: coach1@chalk.local / coach123'); + console.log(' Teacher: teacher1@chalk.local / teacher123'); + console.log('\nView data at: http://localhost:4000'); + console.log('App running at: http://localhost:8081'); + + } catch (error) { + console.error('Seed failed:', error); + } + + process.exit(0); +} + +seed(); diff --git a/scripts/seed-local.js b/scripts/seed-local.js new file mode 100644 index 000000000..183737a81 --- /dev/null +++ b/scripts/seed-local.js @@ -0,0 +1,195 @@ +/** + * Seed script for local Firebase emulators + * Run with: node scripts/seed-local.js + */ + +const firebase = require('firebase'); + +// Firebase config from .env-cmdrc.js (development) - required to initialize SDK before connecting to emulators +const config = { + apiKey: "AIzaSyAdl6szTzbtdD3iq8VoS86ZsMWSxUFtaJ4", + authDomain: "chalk-dev-c6a5d.firebaseapp.com", + projectId: "chalk-dev-c6a5d" +}; + +firebase.initializeApp(config); + +// Connect to emulators +firebase.auth().useEmulator("http://localhost:9099"); +firebase.firestore().useEmulator("localhost", 8080); + +const auth = firebase.auth(); +const db = firebase.firestore(); + +const users = [ + { + email: "admin@chalk.local", + password: "admin123", + data: { + firstName: "Admin", + lastName: "User", + role: "admin", + school: "CHALK Admin Office", + phone: "555-0001", + notes: "", + archived: false + } + }, + { + email: "leader@chalk.local", + password: "leader123", + data: { + firstName: "Program", + lastName: "Leader", + role: "programLeader", + school: "District Office", + phone: "555-0002", + notes: "", + archived: false + } + }, + { + email: "site@chalk.local", + password: "site123", + data: { + firstName: "Site", + lastName: "Leader", + role: "siteLeader", + school: "Lincoln Elementary", + phone: "555-0003", + notes: "", + archived: false + } + }, + { + email: "coach1@chalk.local", + password: "coach123", + data: { + firstName: "Maria", + lastName: "Garcia", + role: "coach", + school: "Lincoln Elementary", + phone: "555-0010", + notes: "", + archived: false + } + }, + { + email: "coach2@chalk.local", + password: "coach123", + data: { + firstName: "John", + lastName: "Smith", + role: "coach", + school: "Washington Elementary", + phone: "555-0011", + notes: "", + archived: false + } + }, + { + email: "teacher1@chalk.local", + password: "teacher123", + data: { + firstName: "Sarah", + lastName: "Johnson", + role: "teacher", + school: "Lincoln Elementary", + phone: "555-0020", + notes: "", + archived: false + } + }, + { + email: "teacher2@chalk.local", + password: "teacher123", + data: { + firstName: "Michael", + lastName: "Brown", + role: "teacher", + school: "Lincoln Elementary", + phone: "555-0021", + notes: "", + archived: false + } + }, + { + email: "teacher3@chalk.local", + password: "teacher123", + data: { + firstName: "Emily", + lastName: "Davis", + role: "teacher", + school: "Washington Elementary", + phone: "555-0022", + notes: "", + archived: false + } + } +]; + +async function seed() { + console.log("=== Seeding Firebase Emulators ===\n"); + + for (const user of users) { + try { + // Create auth user + const userCredential = await auth.createUserWithEmailAndPassword(user.email, user.password); + const uid = userCredential.user.uid; + console.log(`Created auth: ${user.email} (${uid})`); + + // Create Firestore document + await db.collection('users').doc(uid).set({ + ...user.data, + id: uid, + email: user.email + }); + console.log(`Created doc: ${user.data.firstName} ${user.data.lastName} (${user.data.role})`); + + // Sign out before creating next user + await auth.signOut(); + } catch (error) { + console.error(`Error creating ${user.email}:`, error.message); + } + } + + // Create Practice Teacher (special ID used by app) + console.log("\nCreating Practice Teacher..."); + try { + // Need to be signed in to write + await auth.signInWithEmailAndPassword("admin@chalk.local", "admin123"); + + await db.collection('users').doc('rJxNhJmzjRZP7xg29Ko6').set({ + id: 'rJxNhJmzjRZP7xg29Ko6', + firstName: 'Practice', + lastName: 'Teacher', + email: 'practice@teacher.edu', + role: 'teacher', + school: 'Elum Entaree School', + phone: '012-345-6789', + notes: 'Notes are a useful place to put highlights or reminders!', + archived: false + }); + console.log("Created Practice Teacher"); + + await auth.signOut(); + } catch (error) { + console.error("Error creating Practice Teacher:", error.message); + } + + console.log("\n=== Seed Complete ==="); + console.log("\nTest accounts:"); + console.log(" Admin: admin@chalk.local / admin123"); + console.log(" Program Leader: leader@chalk.local / leader123"); + console.log(" Site Leader: site@chalk.local / site123"); + console.log(" Coach 1: coach1@chalk.local / coach123"); + console.log(" Coach 2: coach2@chalk.local / coach123"); + console.log(" Teacher 1: teacher1@chalk.local / teacher123"); + console.log(" Teacher 2: teacher2@chalk.local / teacher123"); + console.log(" Teacher 3: teacher3@chalk.local / teacher123"); + console.log("\nView data at: http://localhost:4000"); + + process.exit(0); +} + +seed().catch(console.error); diff --git a/scripts/seed-local.sh b/scripts/seed-local.sh new file mode 100755 index 000000000..dfa10e256 --- /dev/null +++ b/scripts/seed-local.sh @@ -0,0 +1,123 @@ +#!/bin/bash +# Seed script for local Firebase emulators +# Run this after starting emulators with: npm run firebase:local or the demo project + +AUTH_URL="http://localhost:9099/identitytoolkit.googleapis.com/v1/accounts:signUp?key=fake-api-key" +FIRESTORE_URL="http://localhost:8080/v1/projects/demo-chalk/databases/(default)/documents" + +echo "=== Seeding Firebase Emulators ===" + +# Function to create auth user +create_auth_user() { + local email=$1 + local password=$2 + local uid=$3 + + echo "Creating auth user: $email" + curl -s -X POST "$AUTH_URL" \ + -H "Content-Type: application/json" \ + -d "{ + \"email\": \"$email\", + \"password\": \"$password\", + \"localId\": \"$uid\" + }" > /dev/null +} + +# Function to create Firestore user document +create_user_doc() { + local uid=$1 + local firstName=$2 + local lastName=$3 + local email=$4 + local role=$5 + local school=$6 + + echo "Creating Firestore user: $firstName $lastName ($role)" + curl -s -X PATCH "$FIRESTORE_URL/users/$uid" \ + -H "Content-Type: application/json" \ + -d "{ + \"fields\": { + \"id\": {\"stringValue\": \"$uid\"}, + \"firstName\": {\"stringValue\": \"$firstName\"}, + \"lastName\": {\"stringValue\": \"$lastName\"}, + \"email\": {\"stringValue\": \"$email\"}, + \"role\": {\"stringValue\": \"$role\"}, + \"archived\": {\"booleanValue\": false}, + \"school\": {\"stringValue\": \"$school\"}, + \"phone\": {\"stringValue\": \"555-0100\"}, + \"notes\": {\"stringValue\": \"\"} + } + }" > /dev/null +} + +# Create users with different roles +echo "" +echo "--- Creating Auth Users ---" + +# Admin user +create_auth_user "admin@chalk.local" "admin123" "admin-001" +# Program Leader +create_auth_user "leader@chalk.local" "leader123" "leader-001" +# Site Leader +create_auth_user "site@chalk.local" "site123" "site-001" +# Coaches +create_auth_user "coach1@chalk.local" "coach123" "coach-001" +create_auth_user "coach2@chalk.local" "coach123" "coach-002" +# Teachers +create_auth_user "teacher1@chalk.local" "teacher123" "teacher-001" +create_auth_user "teacher2@chalk.local" "teacher123" "teacher-002" +create_auth_user "teacher3@chalk.local" "teacher123" "teacher-003" + +echo "" +echo "--- Creating Firestore User Documents ---" + +# Admin +create_user_doc "admin-001" "Admin" "User" "admin@chalk.local" "admin" "CHALK Admin Office" + +# Program Leader +create_user_doc "leader-001" "Program" "Leader" "leader@chalk.local" "programLeader" "District Office" + +# Site Leader +create_user_doc "site-001" "Site" "Leader" "site@chalk.local" "siteLeader" "Lincoln Elementary" + +# Coaches +create_user_doc "coach-001" "Maria" "Garcia" "coach1@chalk.local" "coach" "Lincoln Elementary" +create_user_doc "coach-002" "John" "Smith" "coach2@chalk.local" "coach" "Washington Elementary" + +# Teachers +create_user_doc "teacher-001" "Sarah" "Johnson" "teacher1@chalk.local" "teacher" "Lincoln Elementary" +create_user_doc "teacher-002" "Michael" "Brown" "teacher2@chalk.local" "teacher" "Lincoln Elementary" +create_user_doc "teacher-003" "Emily" "Davis" "teacher3@chalk.local" "teacher" "Washington Elementary" + +# Practice Teacher (special ID used by the app) +echo "Creating Practice Teacher..." +curl -s -X PATCH "$FIRESTORE_URL/users/rJxNhJmzjRZP7xg29Ko6" \ + -H "Content-Type: application/json" \ + -d '{ + "fields": { + "id": {"stringValue": "rJxNhJmzjRZP7xg29Ko6"}, + "firstName": {"stringValue": "Practice"}, + "lastName": {"stringValue": "Teacher"}, + "email": {"stringValue": "practice@teacher.edu"}, + "role": {"stringValue": "teacher"}, + "archived": {"booleanValue": false}, + "school": {"stringValue": "Elum Entaree School"}, + "phone": {"stringValue": "012-345-6789"}, + "notes": {"stringValue": "Notes are a useful place to put highlights or reminders!"} + } + }' > /dev/null + +echo "" +echo "=== Seed Complete ===" +echo "" +echo "Test accounts created:" +echo " Admin: admin@chalk.local / admin123" +echo " Program Leader: leader@chalk.local / leader123" +echo " Site Leader: site@chalk.local / site123" +echo " Coach 1: coach1@chalk.local / coach123" +echo " Coach 2: coach2@chalk.local / coach123" +echo " Teacher 1: teacher1@chalk.local / teacher123" +echo " Teacher 2: teacher2@chalk.local / teacher123" +echo " Teacher 3: teacher3@chalk.local / teacher123" +echo "" +echo "View data at: http://localhost:4000" diff --git a/src/App.tsx b/src/App.tsx index cf002c883..509abec3e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -49,6 +49,7 @@ import CoachingLiteracyInstruction from './views/protected/CoachingResourcesView import LiteracyInstructionPage from './views/protected/LiteracyViews/LiteracyInstructionPage' import LiteracyInstructionResultsPage from './views/protected/LiteracyViews/LiteracyInstructionResultsPage' import AdminPage from './views/protected/AdminViews/AdminPage' +import AllUsersPage from './views/protected/AdminViews/AllUsersPage' import TeamPage from './views/WelcomeViews/TeamPage' import TrainingPage from './views/protected/TrainingPage' import * as ReactGA3 from 'react-ga' @@ -636,6 +637,13 @@ class App extends React.Component { path="/Admin" component={AdminPage} /> + { render={(props: { history: H.History }) : React.ReactElement=> } + /> + } /> playedVideos: Array + lastLogin?: Date } interface Note { @@ -308,6 +309,14 @@ class Firebase { }): Promise => { return this.auth .signInWithEmailAndPassword(userData.email, userData.password) + .then(async (userCredential) => { + if (userCredential.user) { + await this.db.collection('users').doc(userCredential.user.uid).update({ + lastLogin: firebase.firestore.FieldValue.serverTimestamp() + }) + } + return userCredential + }) .catch((error: Error) => { console.error('Error signing in: ', error) alert(error) @@ -4724,6 +4733,54 @@ class Firebase { return result } + /** + * Gets all users from Firestore with relevant information for admin dashboard + * @returns {Array} Array of user objects with id, name, email, role, status, and lastLogin + */ + getAllUsers = async () => { + const result: Array<{ + id: string + firstName: string + lastName: string + email: string + role: string + program: string + archived: boolean + lastLogin: Date | null + }> = [] + + // Fetch all programs to build a lookup map + const programsSnapshot = await this.db.collection('programs').get() + const programsMap = new Map() + programsSnapshot.docs.forEach(doc => { + programsMap.set(doc.id, doc.data().name || '') + }) + + const usersSnapshot = await this.db.collection('users').get() + + usersSnapshot.docs.forEach(doc => { + const data = doc.data() + // Get first program name from user's programs array + let programName = '' + if (data.programs && Array.isArray(data.programs) && data.programs.length > 0) { + const firstProgramId = data.programs[0].id || data.programs[0] + programName = programsMap.get(firstProgramId) || '' + } + result.push({ + id: doc.id, + firstName: data.firstName || '', + lastName: data.lastName || '', + email: data.email || '', + role: data.role || '', + program: programName, + archived: data.archived || false, + lastLogin: data.lastLogin ? data.lastLogin.toDate() : null, + }) + }) + + return result + } + /** * @brief This function gets all the data of teachers from the Firebase Firestore database. * diff --git a/src/components/UsersComponents/AllUsersTable.tsx b/src/components/UsersComponents/AllUsersTable.tsx new file mode 100644 index 000000000..c85d59c63 --- /dev/null +++ b/src/components/UsersComponents/AllUsersTable.tsx @@ -0,0 +1,234 @@ +import { + Grid, + TextField, + TableSortLabel, + TablePagination, + FormControl, + Select, + MenuItem, + InputLabel, + Button, + IconButton, + Switch, + Tooltip, +} from '@material-ui/core' +import React from 'react' +import GetAppIcon from '@material-ui/icons/GetApp' +import EditIcon from '@material-ui/icons/Edit' +import styled from 'styled-components' +import * as Types from '../../constants/Types' + +const TableRow = styled.tr` + background-color: white; + :nth-child(odd) { background-color: rgb(234, 234, 234); } + &:hover { background-color: rgba(9, 136, 236, 0.4); cursor: pointer; } +` + +const TableCell = styled.td` + padding: 4px 8px; + text-align: left; + font-size: 1.25rem; + font-family: "Roboto", "Helvetica", "Arial", sans-serif; + font-weight: 400; + line-height: 1.6; +` + +const StatusBadge = styled.span<{ archived: boolean }>` + padding: 4px 8px; + border-radius: 4px; + font-size: 12px; + background-color: ${props => (props.archived ? '#ffebee' : '#e8f5e9')}; + color: ${props => (props.archived ? '#c62828' : '#2e7d32')}; +` + +interface Props { + users: Types.User[] + onUserClick?: (user: Types.User) => void + onArchiveClick?: (user: Types.User) => void +} + +interface State { + search: string + sortField: string + sortDir: 'asc' | 'desc' + roleFilter: string + statusFilter: string + page: number + perPage: number +} + +class AllUsersTable extends React.Component { + state: State = { + search: '', + sortField: 'lastName', + sortDir: 'asc', + roleFilter: '', + statusFilter: '', + page: 0, + perPage: 10, + } + + handleSort = (field: string) => { + this.setState(s => ({ + sortField: field, + sortDir: s.sortField === field && s.sortDir === 'asc' ? 'desc' : 'asc', + })) + } + + getFilteredUsers = (): Types.User[] => { + const { search, sortField, sortDir, roleFilter, statusFilter } = this.state + let users = [...this.props.users] + + if (search) { + const s = search.toLowerCase() + users = users.filter(u => + `${u.firstName} ${u.lastName} ${u.email} ${u.role} ${u.program}`.toLowerCase().includes(s) + ) + } + if (roleFilter) users = users.filter(u => u.role === roleFilter) + if (statusFilter) users = users.filter(u => u.archived === (statusFilter === 'archived')) + + users.sort((a, b) => { + const aVal = sortField === 'lastLogin' + ? (a.lastLogin?.getTime() || 0) + : String(a[sortField] || '').toLowerCase() + const bVal = sortField === 'lastLogin' + ? (b.lastLogin?.getTime() || 0) + : String(b[sortField] || '').toLowerCase() + return sortDir === 'asc' ? (aVal > bVal ? 1 : -1) : (aVal < bVal ? 1 : -1) + }) + + return users + } + + formatRole = (role: string) => ({ + admin: 'Admin', programLeader: 'Program Leader', siteLeader: 'Site Leader', + coach: 'Coach', teacher: 'Teacher', + }[role] || role) + + formatDate = (d: Date | null) => d ? d.toLocaleString([], { dateStyle: 'short', timeStyle: 'short' }) : 'Never' + + handleExport = () => { + const users = this.getFilteredUsers() + const headers = ['Last Name', 'First Name', 'Email', 'Role', 'Program', 'Status', 'Last Login'] + const escape = (val: string) => `"${(val || '').replace(/"/g, '""')}"` + const rows = users.map(u => [ + escape(u.lastName), escape(u.firstName), escape(u.email), + escape(this.formatRole(u.role)), escape(u.program || ''), + escape(u.archived ? 'Archived' : 'Active'), + escape(this.formatDate(u.lastLogin)) + ].join(',')) + const csv = [headers.join(','), ...rows].join('\n') + const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8;' }) + const link = document.createElement('a') + link.href = URL.createObjectURL(blob) + link.download = `users_${new Date().toISOString().split('T')[0]}.csv` + link.click() + } + + render() { + const { search, sortField, sortDir, roleFilter, statusFilter, page, perPage } = this.state + const filtered = this.getFilteredUsers() + const paginated = filtered.slice(page * perPage, (page + 1) * perPage) + const roles = [...new Set(this.props.users.map(u => u.role))].sort() + + const SortHeader = ({ field, label }: { field: string; label: string }) => ( + this.handleSort(field)}> + + {label} + + + ) + + return ( + + + this.setState({ search: e.target.value, page: 0 })} + style={{ width: 200 }} + /> + + Role + this.setState({ roleFilter: e.target.value as string, page: 0 })}> + All + {roles.map(r => {this.formatRole(r)})} + + + + Status + this.setState({ statusFilter: e.target.value as string, page: 0 })}> + All + Active + Archived + + + } onClick={this.handleExport}> + Export + + + + + + + + + + + + + + + Edit + + + + {paginated.length === 0 ? ( + No users found + ) : paginated.map(user => ( + + {user.lastName} + {user.firstName} + {user.email} + {this.formatRole(user.role)} + {user.program} + e.stopPropagation()} style={{ display: 'flex', alignItems: 'center', gap: 8 }}> + {user.archived ? 'Archived' : 'Active'} + + this.props.onArchiveClick?.(user)} + color="primary" + /> + + + {this.formatDate(user.lastLogin)} + e.stopPropagation()} style={{ textAlign: 'center' }}> + + this.props.onUserClick?.(user)}> + + + + + + ))} + + + + + + this.setState({ page: p })} + onRowsPerPageChange={e => this.setState({ perPage: +e.target.value, page: 0 })} + rowsPerPageOptions={[10, 25, 50]} + labelDisplayedRows={({ from, to, count }) => `Showing ${from}-${to} of ${count} records`} + /> + + + ) + } +} + +export default AllUsersTable diff --git a/src/constants/Types.tsx b/src/constants/Types.tsx index 88b18c8ec..64e162e8f 100644 --- a/src/constants/Types.tsx +++ b/src/constants/Types.tsx @@ -229,7 +229,12 @@ export interface User { lastName: string, id: string, role: string, - programs: Array<{ + programs?: Array<{ id: string - }> + }>, + lastLogin?: Date, + email?: string, + school?: string, + program?: string, + archived?: boolean } diff --git a/src/services/xlsxGenerator.ts b/src/services/xlsxGenerator.ts index 70a695f9c..e7a46af36 100644 --- a/src/services/xlsxGenerator.ts +++ b/src/services/xlsxGenerator.ts @@ -1,4 +1,7 @@ import * as xlsx from 'xlsx' +import * as Types from '../constants/Types' + +type UserXlsxResources = Types.User type ActionPlanXlsxResources = { coachId: string @@ -240,3 +243,29 @@ export const generateConferencePlanXlsx = ( xlsx.utils.book_append_sheet(wb, sheet, 'Conference Plans') return wb } + +const createUsersHeaders = () => { + return ['ID', 'First Name', 'Last Name', 'Email', 'Role', 'School', 'Status', 'Last Login'] +} + +const createUserRow = (user: UserXlsxResources): string[] => { + return [ + user.id, + user.firstName, + user.lastName, + user.email, + user.role, + user.school, + user.archived ? 'Archived' : 'Active', + convertDate(user.lastLogin) + ] +} + +export const generateUsersXlsx = (resources: UserXlsxResources[]) => { + const wb = xlsx.utils.book_new() + const rows = [createUsersHeaders(), ...resources.map(createUserRow)] + let sheet = xlsx.utils.aoa_to_sheet(rows) + sheet[`!cols`] = [{ wch: 28 }, { wch: 15 }, { wch: 15 }, { wch: 30 }, { wch: 15 }, { wch: 20 }, { wch: 10 }, { wch: 15 }] + xlsx.utils.book_append_sheet(wb, sheet, 'Users') + return wb +} diff --git a/src/views/protected/AdminViews/AllUsersPage.tsx b/src/views/protected/AdminViews/AllUsersPage.tsx new file mode 100644 index 000000000..54615d1d6 --- /dev/null +++ b/src/views/protected/AdminViews/AllUsersPage.tsx @@ -0,0 +1,146 @@ +import * as React from 'react' +import { Grid, Typography, Dialog, DialogTitle, DialogContent, DialogActions, Button, TextField } from '@material-ui/core' +import CHALKLogoGIF from '../../../assets/images/CHALKLogoGIF.gif' +import Firebase, { FirebaseContext } from '../../../components/Firebase' +import AppBar from '../../../components/AppBar' +import { connect } from 'react-redux' +import { Role } from '../../../state/actions/coach' +import AllUsersTable from '../../../components/UsersComponents/AllUsersTable' +import * as Types from '../../../constants/Types' + +interface Props { isAdmin: boolean } +interface State { + loading: boolean + users: Types.User[] + editOpen: boolean + archiveOpen: boolean + selected: Types.User | null + firstName: string + lastName: string + email: string +} + +class AllUsersPage extends React.Component { + static contextType = FirebaseContext + context!: Firebase + + state: State = { + loading: true, users: [], editOpen: false, archiveOpen: false, + selected: null, firstName: '', lastName: '', email: '', + } + + componentDidMount() { + this.context.getAllUsers() + .then(users => this.setState({ users, loading: false })) + .catch(() => { alert('Error loading users'); this.setState({ loading: false }) }) + } + + handleUserClick = (user: Types.User) => { + this.setState({ + selected: user, firstName: user.firstName, lastName: user.lastName, + email: user.email, editOpen: true, + }) + } + + handleSave = async () => { + const { selected, firstName, lastName, email } = this.state + if (!selected || !firstName.trim() || !lastName.trim()) return alert('Name required') + + await this.context.editUserName(selected.id, firstName, lastName, email, selected.role) + this.setState(s => ({ + users: s.users.map(u => u.id === selected.id ? { ...u, firstName, lastName, email } : u), + editOpen: false, selected: null, + })) + } + + handleArchive = async () => { + const { selected } = this.state + if (!selected) return + + await this.context.db.collection('users').doc(selected.id).update({ archived: !selected.archived }) + this.setState(s => ({ + users: s.users.map(u => u.id === selected.id ? { ...u, archived: !selected.archived } : u), + archiveOpen: false, selected: null, + })) + } + + render() { + const { isAdmin } = this.props + const { loading, users, editOpen, archiveOpen, selected, firstName, lastName, email } = this.state + + return ( + + + {(firebase: Firebase) => } + + + {!isAdmin ? ( + You must be an admin to access this page. + ) : ( + + All Users + {loading ? ( + + + + ) : ( + this.setState({ selected: user, archiveOpen: true })} + /> + )} + + )} + + this.setState({ editOpen: false })} maxWidth="sm" fullWidth> + Edit User + + + + this.setState({ firstName: e.target.value })} /> + + + this.setState({ lastName: e.target.value })} /> + + + this.setState({ email: e.target.value })} /> + + + + + this.setState({ editOpen: false, archiveOpen: true })} + color={selected?.archived ? 'primary' : 'secondary'}> + {selected?.archived ? 'Unarchive' : 'Archive'} + + + this.setState({ editOpen: false })}>Cancel + Save + + + + this.setState({ archiveOpen: false })}> + {selected?.archived ? 'Unarchive' : 'Archive'} User + + + Are you sure you want to {selected?.archived ? 'unarchive' : 'archive'} {selected?.firstName} {selected?.lastName}? + + + + this.setState({ archiveOpen: false })}>Cancel + + {selected?.archived ? 'Unarchive' : 'Archive'} + + + + + ) + } +} + +export default connect(state => ({ + isAdmin: state.coachState.role === Role.ADMIN, +}))(AllUsersPage) diff --git a/src/views/protected/UsersViews/UsersPage.tsx b/src/views/protected/UsersViews/UsersPage.tsx index 85cfd0d76..df0dfd56f 100644 --- a/src/views/protected/UsersViews/UsersPage.tsx +++ b/src/views/protected/UsersViews/UsersPage.tsx @@ -17,6 +17,8 @@ import Coaches from "../../../components/UsersComponents/Coaches"; import Skeleton from "./Skeleton" import Archives from "../../../components/UsersComponents/Archives"; import Sites from "../../../components/UsersComponents/Sites"; +import AllUsersTable from "../../../components/UsersComponents/AllUsersTable"; +import CHALKLogoGIF from '../../../assets/images/CHALKLogoGIF.gif'; const styles: object = { @@ -108,6 +110,8 @@ interface State { currentPage: string propFilter: Array sendToSites: Array + allUsers: Types.User[] + allUsersLoading: boolean } function checkCurrent(item: string) { @@ -136,7 +140,9 @@ class UsersPage extends React.Component { archivedTeachers: [], archivedCoaches: [], propFilter: [], - sendToSites: [] + sendToSites: [], + allUsers: [], + allUsersLoading: true } } @@ -510,6 +516,10 @@ class UsersPage extends React.Component { let sendToSites = this.buildSiteData(coachData, siteData, programData, filter ? filter : []); this.setState({sendToSites: sendToSites}); + // Load All Users if on that route and user is admin + if (this.props.userRole === 'admin' && location.pathname === '/LeadersAllUsers') { + this.loadAllUsers() + } } @@ -546,6 +556,29 @@ class UsersPage extends React.Component { this.setState({programData: data}) } + loadAllUsers = async () => { + if (this.props.userRole !== 'admin') return + this.setState({ allUsersLoading: true }) + try { + const users = await this.context.getAllUsers() + this.setState({ allUsers: users, allUsersLoading: false }) + } catch (e) { + console.error('Error loading all users:', e) + this.setState({ allUsersLoading: false }) + } + } + + handleAllUserClick = (user: Types.User) => { + // Could open edit dialog - for now just log + console.log('User clicked:', user) + } + + handleAllUserArchive = async (user: Types.User) => { + await this.context.db.collection('users').doc(user.id).update({ archived: !user.archived }) + this.setState(s => ({ + allUsers: s.allUsers.map(u => u.id === user.id ? { ...u, archived: !user.archived } : u) + })) + } static propTypes = { classes: PropTypes.exact({ @@ -589,6 +622,16 @@ class UsersPage extends React.Component { ) })} + {userRole === 'admin' && ( + + { + this.loadAllUsers() + this.props.history.push('/LeadersAllUsers') + }}> + All Users + + + )} @@ -604,6 +647,31 @@ class UsersPage extends React.Component { + + userRole === 'admin' ? ( + this.state.allUsersLoading ? ( + + + + ) : ( + + + + ) + ) : ( + You must be an admin to access this page. + ) + } /> this.changePage(pageName)}