diff --git a/frontend/babel.config.js b/frontend/babel.config.js
new file mode 100644
index 0000000..a171609
--- /dev/null
+++ b/frontend/babel.config.js
@@ -0,0 +1,7 @@
+export default {
+ presets: [
+ "@babel/preset-env",
+ "@babel/preset-react"
+ ],
+ };
+
\ No newline at end of file
diff --git a/frontend/coverage/clover.xml b/frontend/coverage/clover.xml
new file mode 100644
index 0000000..e99428d
--- /dev/null
+++ b/frontend/coverage/clover.xml
@@ -0,0 +1,278 @@
+
+
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 | + + + + + + + + + + + + + +1x +2x +2x +2x +2x +2x +2x + + + + + + + +1x + + + + + + + + + + +1x + +24x +24x +24x + + +24x +6x +6x + +1x + + + + + + + + + + + +5x + + +6x +6x + + + + + +24x +9x +9x + + + + + + +9x + + +9x + + +24x +3x +3x +3x +3x +3x + +2x + + + + +3x +2x + + +1x + + + +1x +1x +1x +1x +1x + + + + + + + + +3x +1x + + + +3x +3x + + +24x +3x +3x +1x + + + + + + + +1x +1x + +1x +1x + +1x + +1x +1x + + + + + + + + +1x + + + + + + + + + + + + +1x + + + + +1x +1x + + + + + + + + + +24x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | /* eslint-disable no-unused-vars */
+/* eslint-disable react/prop-types */
+// src/components/profile/ExperienceForm.jsx
+import React from 'react';
+import { useState, useEffect } from 'react';
+import {
+ Dialog, DialogTitle, DialogContent, DialogActions, Button,
+ TextField, FormControl, InputLabel, Select, MenuItem, Checkbox,
+ FormControlLabel, CircularProgress, Box, FormHelperText, Alert
+} from '@mui/material';
+import { Timestamp } from 'firebase/firestore'; // Needed if handling Timestamps directly
+
+// --- Helper Function to Format Dates for Input ---
+// Converts Firestore Timestamp or Date object to 'YYYY-MM' for month input
+const formatDateForInput = (date) => {
+ Iif (!date) return '';
+ try {
+ const d = date instanceof Timestamp ? date.toDate() : new Date(date);
+ const year = d.getFullYear();
+ const month = (d.getMonth() + 1).toString().padStart(2, '0'); // +1 because months are 0-indexed, pad for '05'
+ return `${year}-${month}`;
+ } catch (e) {
+ console.error("Error formatting date for input:", e);
+ return '';
+ }
+};
+
+// --- Default State ---
+const defaultState = {
+ type: 'work', // Default type
+ title: '',
+ organization: '',
+ startDate: '', // Stored as YYYY-MM string
+ endDate: '', // Stored as YYYY-MM string
+ isCurrent: false,
+ description: '',
+ link: '',
+};
+
+const ExperienceForm = ({ open, onClose, onSave, initialData = null, userId, isSaving }) => {
+ // --- State ---
+ const [formData, setFormData] = useState(defaultState);
+ const [formErrors, setFormErrors] = useState({});
+ const [generalError, setGeneralError] = useState(''); // For errors not specific to a field
+
+ // --- Effect to Reset Form ---
+ useEffect(() => {
+ Eif (open) {
+ if (initialData) {
+ // Editing: Populate form with existing data
+ setFormData({
+ type: initialData.type || defaultState.type,
+ title: initialData.title || defaultState.title,
+ organization: initialData.organization || defaultState.organization,
+ startDate: formatDateForInput(initialData.startDate),
+ endDate: formatDateForInput(initialData.endDate),
+ isCurrent: initialData.isCurrent || defaultState.isCurrent,
+ description: initialData.description || defaultState.description,
+ link: initialData.link || defaultState.link,
+ });
+ } else {
+ // Adding new: Reset to default
+ setFormData(defaultState);
+ }
+ // Clear errors when dialog opens/changes mode
+ setFormErrors({});
+ setGeneralError('');
+ }
+ }, [open, initialData]); // Rerun when dialog opens or initial data changes
+
+
+ // --- Handlers ---
+ const handleChange = (event) => {
+ const { name, value, type, checked } = event.target;
+ setFormData(prev => ({
+ ...prev,
+ [name]: type === 'checkbox' ? checked : value,
+ // If 'isCurrent' is checked, clear the endDate
+ ...(name === 'isCurrent' && checked && { endDate: '' }),
+ }));
+ // Clear specific error when user modifies field
+ Iif (formErrors[name]) {
+ setFormErrors(prev => ({ ...prev, [name]: null }));
+ }
+ Iif(generalError) setGeneralError(''); // Clear general error on any change
+ };
+
+ const validateForm = () => {
+ let errors = {};
+ Iif (!formData.type) errors.type = "Type is required.";
+ if (!formData.title.trim()) errors.title = "Title is required.";
+ if (!formData.organization.trim()) errors.organization = "Organization/Company/Project Name is required.";
+ if (!formData.startDate) errors.startDate = "Start Date is required.";
+ // Validate start date format (basic check)
+ else Iif (!/^\d{4}-\d{2}$/.test(formData.startDate)) {
+ errors.startDate = "Invalid date format (use YYYY-MM).";
+ }
+
+ // End date required only if not 'isCurrent'
+ if (!formData.isCurrent && !formData.endDate) {
+ errors.endDate = "End Date is required if not currently ongoing.";
+ }
+ // Validate end date format (basic check)
+ else Iif (formData.endDate && !/^\d{4}-\d{2}$/.test(formData.endDate)) {
+ errors.endDate = "Invalid date format (use YYYY-MM).";
+ }
+ // Optional: Validate date logic (end date must be after start date)
+ else Eif (formData.startDate && formData.endDate && !formData.isCurrent) {
+ try {
+ const start = new Date(formData.startDate + '-01'); // Append day for Date object
+ const end = new Date(formData.endDate + '-01');
+ Iif (end < start) {
+ errors.endDate = "End Date cannot be before Start Date.";
+ }
+ } catch (e) {
+ errors.startDate = "Invalid date values for comparison."; // Handle potential Date parsing errors
+ }
+ }
+
+ // Optional: Basic URL validation for link
+ if (formData.link && !/^https?:\/\/.+/.test(formData.link)) {
+ errors.link = "Please enter a valid URL (starting with http:// or https://).";
+ }
+
+
+ setFormErrors(errors);
+ return Object.keys(errors).length === 0; // True if no errors
+ };
+
+ const handleSave = async () => {
+ setGeneralError(''); // Clear previous general errors
+ if (!validateForm()) return;
+ Iif (!userId) {
+ setGeneralError("User ID is missing. Cannot save.");
+ return;
+ }
+
+ // --- Prepare data for saving ---
+ // Convert YYYY-MM strings back to Firestore Timestamps (or Date objects)
+ // Store as the *first day* of the given month.
+ let startTimestamp = null;
+ let endTimestamp = null;
+
+ try {
+ Eif (formData.startDate) {
+ // Add '-01' to make it a valid date string for Date constructor
+ startTimestamp = Timestamp.fromDate(new Date(formData.startDate + '-01'));
+ }
+ Eif (formData.endDate && !formData.isCurrent) {
+ endTimestamp = Timestamp.fromDate(new Date(formData.endDate + '-01'));
+ }
+ } catch (e) {
+ console.error("Error converting dates to Timestamps:", e);
+ setGeneralError("Invalid date format encountered. Please check dates.");
+ return; // Stop saving if date conversion fails
+ }
+
+
+ const experienceData = {
+ type: formData.type,
+ title: formData.title.trim(),
+ organization: formData.organization.trim(),
+ startDate: startTimestamp, // Use the Timestamp
+ isCurrent: formData.isCurrent,
+ description: formData.description.trim(),
+ link: formData.link.trim(),
+ // Only include endDate if it's valid and not current
+ ...(endTimestamp && !formData.isCurrent && { endDate: endTimestamp }),
+ };
+
+ // Add or remove endDate based on isCurrent
+ Iif (formData.isCurrent) {
+ delete experienceData.endDate; // Ensure endDate is not present if current
+ }
+
+ // Call the passed-in onSave function (which handles Firestore)
+ try {
+ await onSave(experienceData, initialData?.id || null); // Pass data and ID (if editing)
+ // onClose(); // Optionally close dialog on successful save (handled by parent potentially)
+ } catch (error) {
+ console.error("Error saving experience:", error);
+ setGeneralError(`Failed to save experience: ${error.message}`);
+ }
+ };
+
+
+ // --- Render ---
+ return (
+ // Prevent closing while saving action is in progress
+ <Dialog open={open} onClose={() => !isSaving && onClose()} maxWidth="sm" fullWidth>
+ <DialogTitle>{initialData ? 'Edit Experience' : 'Add New Experience'}</DialogTitle>
+ <DialogContent>
+ {generalError && <Alert severity="error" sx={{ mb: 2 }}>{generalError}</Alert>}
+
+ {/* --- FORM FIELDS (Add below) --- */}
+ <Box component="form" noValidate autoComplete="off">
+ {/* Type (Dropdown) */}
+ <FormControl fullWidth margin="dense" required error={!!formErrors.type} disabled={isSaving}>
+ <InputLabel id="experience-type-label">Type</InputLabel>
+ <Select
+ labelId="experience-type-label"
+ name="type"
+ value={formData.type}
+ label="Type"
+ onChange={handleChange}
+ >
+ <MenuItem value="work">Work Experience</MenuItem>
+ <MenuItem value="research">Research Experience</MenuItem>
+ <MenuItem value="project">Project</MenuItem>
+ <MenuItem value="volunteer">Volunteer</MenuItem>
+ <MenuItem value="other">Other</MenuItem>
+ {/* Add other relevant types if needed */}
+ </Select>
+ {formErrors.type && <FormHelperText>{formErrors.type}</FormHelperText>}
+ </FormControl>
+
+ {/* Title */}
+ <TextField
+ margin="dense"
+ name="title"
+ label="Title / Role"
+ type="text"
+ fullWidth
+ variant="outlined"
+ value={formData.title}
+ onChange={handleChange}
+ required
+ error={!!formErrors.title}
+ helperText={formErrors.title}
+ disabled={isSaving}
+ />
+
+ {/* Organization / Company / Project Name */}
+ <TextField
+ margin="dense"
+ name="organization"
+ label="Organization / Company / Project Name"
+ type="text"
+ fullWidth
+ variant="outlined"
+ value={formData.organization}
+ onChange={handleChange}
+ required
+ error={!!formErrors.organization}
+ helperText={formErrors.organization}
+ disabled={isSaving}
+ />
+
+ {/* Dates */}
+ <Box sx={{ display: 'flex', gap: 2, mt: 1 }}>
+ {/* Start Date */}
+ <TextField
+ margin="dense"
+ name="startDate"
+ label="Start Date"
+ type="month" // Use month input type
+ fullWidth
+ variant="outlined"
+ value={formData.startDate} // Should be YYYY-MM
+ onChange={handleChange}
+ required
+ error={!!formErrors.startDate}
+ helperText={formErrors.startDate || "YYYY-MM"}
+ disabled={isSaving}
+ InputLabelProps={{
+ shrink: true, // Keep label floated
+ }}
+ />
+ {/* End Date */}
+ <TextField
+ margin="dense"
+ name="endDate"
+ label="End Date"
+ type="month"
+ fullWidth
+ variant="outlined"
+ value={formData.endDate} // Should be YYYY-MM
+ onChange={handleChange}
+ required={!formData.isCurrent} // Required only if not current
+ error={!!formErrors.endDate}
+ helperText={formErrors.endDate || "YYYY-MM"}
+ disabled={isSaving || formData.isCurrent} // Disable if saving OR if 'isCurrent' is checked
+ InputLabelProps={{
+ shrink: true,
+ }}
+ />
+ </Box>
+
+ {/* Currently Working Checkbox */}
+ <FormControlLabel
+ control={
+ <Checkbox
+ name="isCurrent"
+ checked={formData.isCurrent}
+ onChange={handleChange}
+ color="primary"
+ disabled={isSaving}
+ />
+ }
+ label="I am currently working / involved in this role"
+ sx={{ mt: 1, display: 'block' }} // Ensure it takes full width block
+ />
+
+ {/* Description */}
+ <TextField
+ margin="dense"
+ name="description"
+ label="Description (Optional)"
+ type="text"
+ fullWidth
+ variant="outlined"
+ multiline
+ rows={4}
+ value={formData.description}
+ onChange={handleChange}
+ error={!!formErrors.description}
+ helperText={formErrors.description}
+ disabled={isSaving}
+ />
+
+ {/* Link */}
+ <TextField
+ margin="dense"
+ name="link"
+ label="Related Link (Optional)"
+ type="url" // Use URL type for better semantics/validation
+ fullWidth
+ variant="outlined"
+ value={formData.link}
+ onChange={handleChange}
+ error={!!formErrors.link}
+ helperText={formErrors.link || "e.g., https://project-link.com"}
+ disabled={isSaving}
+ />
+
+ </Box>
+
+ </DialogContent>
+ <DialogActions sx={{ p: '16px 24px'}}>
+ <Button onClick={onClose} disabled={isSaving}>Cancel</Button>
+ <Button onClick={handleSave} variant="contained" disabled={isSaving}>
+ {isSaving ? <CircularProgress size={24} color="inherit"/> : (initialData ? 'Update Experience' : 'Add Experience')}
+ </Button>
+ </DialogActions>
+ </Dialog>
+ );
+};
+
+export default ExperienceForm; |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 | + + + + + + + + + + + + + + + + + + + + +1x + + +1x + + + + + + + + + + + +1x +2x + + + + + +1x + +10x +10x +10x +10x + + +10x +10x +10x + + +10x +10x +10x + + +10x +10x +10x + + + + +10x + + + +10x +3x + + + +10x +3x +3x +3x +3x +3x +3x + +3x +3x +3x +3x + + + + + + + + + + +3x + + + + +10x + + + + + + +10x +1x +1x +1x + + + + + +10x +1x +1x +1x +3x + + +1x + + +1x + +1x +1x +1x +1x +1x +1x + + + +1x + + + +10x + + + + + + + + + + + + + + + + + +10x +10x +10x + + +10x + + + + + + + + + + + + + + + + + + + + + + + + + + + + +10x + + + + + + + + + + + + + + + + + + + +10x + + + + + +10x + + +10x + + + + + + + + + +1x +1x + + + + +30x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | /* eslint-disable no-unused-vars */
+/* eslint-disable react/prop-types */
+// src/components/profile/ProfessorExperienceResearch.jsx
+// (Adapted from StudentExperienceResearch.jsx)
+
+import React, { useState, useEffect } from 'react';
+import { Box, Typography, TextField, Button, Chip, Stack, IconButton, CircularProgress, Alert as MuiAlert, Paper, Divider, Link as MuiLink, Snackbar } from '@mui/material';
+import { doc, updateDoc, arrayUnion, arrayRemove, collection, query, onSnapshot, orderBy, addDoc, deleteDoc } from 'firebase/firestore';
+import { db, auth } from '../../firebase'; // Adjust path if needed
+
+// Re-use the same form component
+import ExperienceForm from './ExperienceForm';
+
+// --- Icons ---
+import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline';
+import CancelIcon from '@mui/icons-material/Cancel';
+import EditIcon from '@mui/icons-material/Edit';
+import DeleteIcon from '@mui/icons-material/Delete';
+import AddIcon from '@mui/icons-material/Add';
+
+// --- Define the tag limit ---
+const TAG_LIMIT = 15; // Or maybe professors have a different limit? Keep same for now.
+
+// --- Helper function to format Dates ---
+const formatExperienceDate = (timestamp) => {
+ if (!timestamp?.toDate) return 'N/A';
+ try {
+ return timestamp.toDate().toLocaleDateString(undefined, { year: 'numeric', month: 'short'});
+ } catch (e) {
+ console.error("Error formatting date:", e, timestamp);
+ return 'Invalid Date';
+ }
+};
+// --- End Date Helper ---
+
+// --- Snackbar Alert ForwardRef (Needed for Alert inside Snackbar) ---
+const Alert = React.forwardRef(function Alert(props, ref) {
+ return <MuiAlert elevation={6} ref={ref} variant="filled" {...props} />;
+});
+// --- End Snackbar Alert ---
+
+
+// Expects professorData containing the user profile, including UID
+const ProfessorExperienceResearch = ({ professorData }) => {
+ // State for Tags
+ const [newTag, setNewTag] = useState('');
+ const [displayTags, setDisplayTags] = useState(professorData?.experienceTags || []);
+ const [isProcessingTag, setIsProcessingTag] = useState(false);
+ const [tagError, setTagError] = useState('');
+
+ // State for Structured Experiences
+ const [experiences, setExperiences] = useState([]);
+ const [loadingExperiences, setLoadingExperiences] = useState(true);
+ const [experienceError, setExperienceError] = useState(null); // Error during fetch
+
+ // State for Experience Modal
+ const [isExperienceModalOpen, setIsExperienceModalOpen] = useState(false);
+ const [editingExperienceData, setEditingExperienceData] = useState(null);
+ const [isSavingExperience, setIsSavingExperience] = useState(false);
+
+ // --- Snackbar State ---
+ const [snackbarOpen, setSnackbarOpen] = useState(false);
+ const [snackbarMessage, setSnackbarMessage] = useState('');
+ const [snackbarSeverity, setSnackbarSeverity] = useState('info'); // 'success', 'error', 'warning', 'info'
+
+
+ // Professor's User ID (assuming passed in professorData or use auth)
+ // Use authenticated user's ID since this is for *their own* profile editing
+ const userId = auth.currentUser?.uid;
+
+ // --- Effects ---
+ // Update local tags if prop changes (e.g., parent refetches professorData)
+ useEffect(() => {
+ setDisplayTags(professorData?.experienceTags || []);
+ }, [professorData?.experienceTags]);
+
+ // Fetch structured experiences for the logged-in professor
+ useEffect(() => {
+ let unsubscribe = () => {};
+ if (userId) {
+ setLoadingExperiences(true);
+ setExperienceError(null);
+ const experiencesCollectionRef = collection(db, 'users', userId, 'experiences');
+ const q = query(experiencesCollectionRef, orderBy('startDate', 'desc'));
+
+ unsubscribe = onSnapshot(q, (querySnapshot) => {
+ const fetchedExperiences = querySnapshot.docs.map(doc => ({ id: doc.id, ...doc.data() }));
+ setExperiences(fetchedExperiences);
+ setLoadingExperiences(false);
+ }, (error) => {
+ console.error("Error listening to professor experiences:", error);
+ setExperienceError("Failed to load detailed experiences.");
+ setLoadingExperiences(false);
+ });
+ } else E{
+ setExperiences([]);
+ setLoadingExperiences(false);
+ setExperienceError("Cannot load experiences: User not authenticated.");
+ }
+ return () => unsubscribe();
+ }, [userId]); // Depend on userId
+
+
+ // --- Snackbar Handler ---
+ const handleSnackbarClose = (event, reason) => {
+ if (reason === 'clickaway') {
+ return;
+ }
+ setSnackbarOpen(false);
+ };
+
+ const showSnackbar = (message, severity = 'info') => {
+ setSnackbarMessage(message);
+ setSnackbarSeverity(severity);
+ setSnackbarOpen(true);
+ };
+ // --- End Snackbar Handler ---
+
+
+ // --- Tag Handlers (Identical to student version) ---
+ const handleAddTag = async () => {
+ const tagToAdd = newTag.trim();
+ setTagError('');
+ Iif (!tagToAdd) { setTagError('Tag cannot be empty.'); return; }
+ Iif (displayTags.some(tag => tag.toLowerCase() === tagToAdd.toLowerCase())) {
+ setTagError(`Tag "${tagToAdd}" already exists.`); setNewTag(''); return;
+ }
+ Iif (displayTags.length >= TAG_LIMIT) {
+ setTagError(`Tag limit (${TAG_LIMIT}) reached.`); setNewTag(''); return;
+ }
+ Iif (!userId) { setTagError("Not authenticated."); return; }
+
+ setIsProcessingTag(true);
+ const userDocRef = doc(db, 'users', userId);
+ try {
+ await updateDoc(userDocRef, { experienceTags: arrayUnion(tagToAdd) });
+ setNewTag('');
+ showSnackbar(`Tag "${tagToAdd}" added successfully!`, 'success'); // Example Snackbar
+ } catch (err) {
+ console.error("Error adding tag:", err); setTagError("Failed to add tag."); showSnackbar('Failed to add tag.', 'error');
+ } finally {
+ setIsProcessingTag(false);
+ }
+ };
+
+ const handleRemoveTag = async (tagToRemove) => {
+ if (!userId) { setTagError("Not authenticated."); return; }
+ if (isProcessingTag) return;
+ setIsProcessingTag(true); setTagError('');
+ const userDocRef = doc(db, 'users', userId);
+ try {
+ await updateDoc(userDocRef, { experienceTags: arrayRemove(tagToRemove) });
+ showSnackbar(`Tag "${tagToRemove}" removed.`, 'success'); // Example Snackbar
+ } catch (err) {
+ console.error("Error removing tag:", err); setTagError("Failed to remove tag."); showSnackbar('Failed to remove tag.', 'error');
+
+ } finally {
+ setIsProcessingTag(false);
+ }
+ };
+
+
+ // --- Experience Modal Handlers (keep as is) ---
+ const handleOpenAddExperienceModal = () => { setEditingExperienceData(null); setIsExperienceModalOpen(true); };
+ const handleOpenEditExperienceModal = (experienceData) => { setEditingExperienceData(experienceData); setIsExperienceModalOpen(true); };
+ const handleCloseExperienceModal = () => { setIsExperienceModalOpen(false); };
+
+ // --- Experience CRUD Handlers (Modified for Snackbar) ---
+ const handleSaveExperience = async (formData, experienceId) => {
+ if (!userId) {
+ showSnackbar("Authentication error. Cannot save.", "error");
+ throw new Error("User not authenticated.");
+ }
+ setIsSavingExperience(true);
+ // setCrudError(null); // Not needed if using snackbar for feedback
+ try {
+ let message = '';
+ if (experienceId) { // Update
+ const experienceDocRef = doc(db, 'users', userId, 'experiences', experienceId);
+ await updateDoc(experienceDocRef, formData);
+ message = 'Experience updated successfully!';
+ } else { // Add
+ const experiencesCollectionRef = collection(db, 'users', userId, 'experiences');
+ await addDoc(experiencesCollectionRef, formData);
+ message = 'Experience added successfully!';
+ }
+ handleCloseExperienceModal();
+ showSnackbar(message, 'success'); // Show success message
+ } catch (err) {
+ console.error("Error saving experience:", err);
+ showSnackbar(`Failed to save experience: ${err.message}`, 'error'); // Show error message
+ throw err; // Re-throw so ExperienceForm can also catch it if needed
+ } finally {
+ setIsSavingExperience(false);
+ }
+ };
+
+ const handleDeleteExperience = async (experienceId) => {
+ if (!userId) { showSnackbar("Authentication error.", "error"); return; }
+ if (isSavingExperience) return;
+ if (!window.confirm("Are you sure you want to delete this experience?")) return;
+
+ setIsSavingExperience(true);
+ // setCrudError(null);
+ try {
+ const experienceDocRef = doc(db, 'users', userId, 'experiences', experienceId);
+ await deleteDoc(experienceDocRef);
+ showSnackbar("Experience deleted successfully.", 'success'); // Show success
+ } catch (err) {
+ console.error("Error deleting experience:", err);
+ showSnackbar(`Failed to delete experience: ${err.message}`, 'error'); // Show error
+ } finally {
+ setIsSavingExperience(false);
+ }
+ };
+
+ // --- Grouping and Rendering Logic (Identical) ---
+ const groupedExperiences = experiences.reduce((acc, exp) => {
+ const type = exp.type || 'other';
+ if (!acc[type]) { acc[type] = []; }
+ acc[type].push(exp);
+ return acc;
+ }, {});
+ const limitReached = displayTags.length >= TAG_LIMIT;
+
+ // --- Render ---
+ return (
+ <Box sx={{ mt: 4 }}> {/* Add some margin if needed */}
+ {/* --- Tags Section --- */}
+ <Typography variant="h5" gutterBottom>
+ My Experience & Research Areas {/* Slightly different title? */}
+ </Typography>
+ <Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
+ Add relevant keywords for your work, research areas, or skills. Max {TAG_LIMIT}.
+ </Typography>
+ {/* ... (Tag input form - identical) ... */}
+ <Box component="form" onSubmit={(e) => { e.preventDefault(); handleAddTag(); }} sx={{ display: 'flex', gap: 1, alignItems: 'center', mb: 3 }}>
+ <TextField label={limitReached ? `Tag limit (${TAG_LIMIT}) reached` : "Add New Tag/Keyword"} variant="outlined" size="small" value={newTag} onChange={(e) => setNewTag(e.target.value)} disabled={isProcessingTag || limitReached} sx={{ flexGrow: 1 }} />
+ <Button type="submit" variant="contained" startIcon={isProcessingTag ? <CircularProgress size={20} color="inherit" /> : <AddCircleOutlineIcon />} disabled={isProcessingTag || !newTag.trim() || limitReached}> Add </Button>
+ </Box>
+ {tagError && <Alert severity="warning" sx={{ mb: 2 }}>{tagError}</Alert>}
+ <Typography variant="h6" gutterBottom sx={{ fontWeight: 'medium' }}> Current Tags/Keywords ({displayTags.length}/{TAG_LIMIT}): </Typography>
+ {displayTags.length > 0 ? ( <Stack direction="row" flexWrap="wrap" spacing={1} useFlexGap> {displayTags.map((tag) => ( <Chip key={tag} label={tag} onDelete={isProcessingTag ? undefined : () => handleRemoveTag(tag)} deleteIcon={<CancelIcon />} disabled={isProcessingTag} /> ))} </Stack> ) : ( <Typography variant="body2" color="text.secondary"> No tags added yet. </Typography> )}
+
+ <Divider sx={{ my: 4 }} />
+
+ {/* --- Structured Experience Section --- */}
+ <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
+ <Typography variant="h5" gutterBottom component="div">Detailed Experience / CV</Typography>
+ <Button variant="contained" startIcon={<AddIcon />} onClick={handleOpenAddExperienceModal} size="small" disabled={isSavingExperience}>
+ Add Experience
+ </Button>
+ </Box>
+ {/* Display fetch errors */}
+ {experienceError && !loadingExperiences && <Alert severity="error" sx={{mb: 2}}>{experienceError}</Alert>}
+ {loadingExperiences && <Box sx={{ display: 'flex', justifyContent: 'center', my: 3}}><CircularProgress size={24} /></Box>}
+ {experienceError && !loadingExperiences && <Alert severity="error">{experienceError}</Alert>}
+ {!loadingExperiences && !experienceError && experiences.length === 0 && (
+ <Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center', mt: 2 }}> No detailed experiences added yet. </Typography>
+ )}
+ {/* ... (Experience list rendering - identical logic, uses professor's data) ... */}
+ {!loadingExperiences && !experienceError && experiences.length > 0 && (
+ <Stack spacing={4} sx={{ mt: 2 }}>
+ {Object.entries(groupedExperiences).map(([type, exps]) => (
+ <Box key={type}>
+ <Typography variant="h6" gutterBottom sx={{ fontWeight: 'medium', textTransform: 'capitalize' }}> {type === 'work' ? 'Work Experience' : type === 'research' ? 'Research Experience' : type === 'project' ? 'Projects' : type === 'volunteer' ? 'Volunteer Experience' : 'Other Experience' } </Typography>
+ <Stack spacing={2}>
+ {exps.map(exp => (
+ <Paper key={exp.id} variant="outlined" sx={{ p: 2, position: 'relative' }}>
+ <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 1 }}>
+ <Box>
+ <Typography sx={{ fontWeight: 'bold' }}>{exp.title || 'N/A'}</Typography>
+ <Typography variant="body2" color="text.secondary">{exp.organization || 'N/A'}</Typography>
+ <Typography variant="caption" color="text.secondary"> {formatExperienceDate(exp.startDate)} - {exp.isCurrent ? 'Present' : formatExperienceDate(exp.endDate)} </Typography>
+ </Box>
+ <Box sx={{ display: 'flex', gap: 0.5 }}>
+ <IconButton size="small" onClick={() => handleOpenEditExperienceModal(exp)} disabled={isSavingExperience} aria-label="Edit experience"><EditIcon fontSize='small'/></IconButton>
+ <IconButton size="small" onClick={() => handleDeleteExperience(exp.id)} disabled={isSavingExperience} color="error" aria-label="Delete experience"><DeleteIcon fontSize='small' /></IconButton>
+ </Box>
+ </Box>
+ {exp.description && <Typography variant="body2" sx={{ mt: 1, whiteSpace: 'pre-wrap' }}>{exp.description}</Typography>}
+ {exp.link && <MuiLink href={exp.link} target="_blank" rel="noopener noreferrer" variant="caption" sx={{ display: 'block', mt: 0.5 }}>Visit Link</MuiLink>}
+ </Paper>
+ ))}
+ </Stack>
+ </Box>
+ ))}
+ </Stack>
+ )}
+
+ {/* --- Render ExperienceForm Modal (Re-uses the same component) --- */}
+ <ExperienceForm
+ open={isExperienceModalOpen}
+ onClose={handleCloseExperienceModal}
+ onSave={handleSaveExperience}
+ initialData={editingExperienceData}
+ userId={userId}
+ isSaving={isSavingExperience}
+ />
+
+ {/* --- Snackbar Component --- */}
+ <Snackbar
+ open={snackbarOpen}
+ autoHideDuration={5000} // Hide after 6 seconds
+ onClose={handleSnackbarClose}
+ anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} // Position
+ >
+ {/* The Alert component integrates with Snackbar */}
+ <Alert onClose={handleSnackbarClose} severity={snackbarSeverity} sx={{ width: '100%' }}>
+ {snackbarMessage}
+ </Alert>
+ </Snackbar>
+ {/* --- End Snackbar --- */}
+ </Box>
+ );
+};
+
+export default ProfessorExperienceResearch; |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 | + + + + + + +1x + + + + + + + + +8x +8x + +8x +8x +8x +16x + + +8x + +8x +8x +8x +8x + +8x +2x +1x + + + +8x +2x +2x +1x + +1x + + + + +8x +8x + +8x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | /* eslint-disable react/prop-types */
+/* eslint-disable no-unused-vars */
+// frontend/src/components/profile/ProfileHeader.jsx
+import React, { useState } from 'react';
+import { Box, Avatar, IconButton } from '@mui/material';
+import EditIcon from '@mui/icons-material/Edit';
+
+const ProfileHeader = ({
+ coverLink,
+ photoLink,
+ professorName, // Assuming name is passed for initials fallback
+ onEditCover,
+ onViewCover,
+ onEditPhoto,
+ onViewPhoto,
+}) => {
+ const [coverHover, setCoverHover] = useState(false);
+ const [photoHover, setPhotoHover] = useState(false);
+
+ const getInitials = () => {
+ Iif (!professorName) return '?';
+ const parts = professorName.split(' ');
+ return parts.map((p) => p[0]?.toUpperCase() || '').join('') || '?';
+ };
+
+ const initials = getInitials();
+
+ const handleCoverMouseEnter = () => setCoverHover(true);
+ const handleCoverMouseLeave = () => setCoverHover(false);
+ const handlePhotoMouseEnter = () => setPhotoHover(true);
+ const handlePhotoMouseLeave = () => setPhotoHover(false);
+
+ const handleCoverClick = () => {
+ if (coverLink) { onViewCover(); }
+ else { onEditCover(); }
+ };
+
+ // --- Modify handlePhotoClick ---
+ const handlePhotoClick = (e) => { // Accept event 'e'
+ e.stopPropagation(); // <<< ADD THIS LINE to stop bubbling
+ if (photoLink) {
+ onViewPhoto();
+ } else {
+ onEditPhoto();
+ }
+ };
+ // --- End modification ---
+
+ const handleCoverEditButtonClick = (e) => { e.stopPropagation(); onEditCover(); };
+ const handlePhotoEditButtonClick = (e) => { e.stopPropagation(); onEditPhoto(); };
+
+ return (
+ <Box
+ sx={{
+ position: 'relative',
+ height: { xs: 300, sm: 300 },
+ mb: { xs: 8, sm: 10 }, // Adjusted margin based on avatar size
+ // --- THEME BACKGROUND ---
+ // Use coverLink if available, otherwise use theme secondary color
+ backgroundImage: coverLink ? `url('${coverLink}')` : 'none', // Remove gradient
+ // Use theme's secondary color (Steel Gray) as default background
+ bgcolor: coverLink ? 'transparent' : 'secondary.main',
+ // --- END THEME BACKGROUND ---
+ backgroundSize: 'cover',
+ backgroundPosition: 'center',
+ borderRadius: 1, // Use theme's border radius? theme.shape.borderRadius
+ cursor: 'pointer',
+ '&:hover .cover-hover-edit': { opacity: 1 },
+ }}
+ onClick={handleCoverClick}
+ onMouseEnter={handleCoverMouseEnter}
+ onMouseLeave={handleCoverMouseLeave}
+ >
+ {/* Edit Cover Button (style should still work on dark bg) */}
+ <IconButton
+ className="cover-hover-edit"
+ size="small"
+ sx={{
+ position: 'absolute', top: 8, right: 8, color: 'white',
+ backgroundColor: 'rgba(0, 0, 0, 0.4)',
+ opacity: coverHover ? 1 : 0,
+ transition: 'opacity 0.2s',
+ '&:hover': { backgroundColor: 'rgba(0, 0, 0, 0.7)' },
+ }}
+ onClick={handleCoverEditButtonClick}
+ aria-label="Edit cover photo"
+ >
+ <EditIcon fontSize="small"/>
+ </IconButton>
+
+ {/* Avatar */}
+ <Box
+ sx={{
+ width: { xs: 100, sm: 120, md: 140 }, // Slightly adjusted sizes
+ height: { xs: 100, sm: 120, md: 140 },
+ position: 'absolute',
+ bottom: { xs: -50, sm: -60, md: -70 }, // Adjust overlap based on size
+ left: { xs: '50%', sm: 24 }, // Indent more on larger screens
+ transform: { xs: 'translateX(-50%)', sm: 'none' },
+ border: '4px solid', // Use theme paper color for border
+ borderColor: 'background.paper', // Ensure good contrast
+ borderRadius: '50%',
+ bgcolor: 'grey.300', // Fallback bg for avatar itself
+ overflow: 'hidden',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ cursor: 'pointer',
+ boxShadow: 3,
+ '&:hover .photo-hover-edit': { opacity: 1 },
+ }}
+ onClick={handlePhotoClick}
+ onMouseEnter={handlePhotoMouseEnter}
+ onMouseLeave={handlePhotoMouseLeave}
+ >
+ <Avatar
+ src={photoLink || ''}
+ alt={professorName || 'User'} // Generic alt
+ sx={{
+ width: '100%', height: '100%',
+ fontSize: { xs: '2.5rem', sm: '3rem', md: '3.5rem' }, // Adjust font size
+ // Use theme primary colors for Avatar fallback
+ bgcolor: 'primary.main',
+ color: 'primary.contrastText'
+ }}
+ >
+ {!photoLink && initials}
+ </Avatar>
+ {/* Edit Photo Button (style should still work) */}
+ <IconButton
+ className="photo-hover-edit"
+ size="small"
+ sx={{
+ position: 'absolute', bottom: 5, right: 5,
+ color: 'white', backgroundColor: 'rgba(0, 0, 0, 0.4)',
+ opacity: photoHover ? 1 : 0,
+ transition: 'opacity 0.2s',
+ '&:hover': { backgroundColor: 'rgba(0, 0, 0, 0.7)' },
+ }}
+ onClick={handlePhotoEditButtonClick}
+ aria-label="Edit profile photo"
+ >
+ <EditIcon fontSize="small"/>
+ </IconButton>
+ </Box>
+ </Box>
+ );
+};
+
+export default ProfileHeader; |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 | + + + + + + + + +1x + + + + + + + + + + + +3x + +3x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | /* eslint-disable react/prop-types */
+/* eslint-disable no-unused-vars */
+// frontend/src/components/profile/ProfileInfoSection.jsx
+import React from 'react';
+import { Box, Typography } from '@mui/material';
+import EditableField from './../common/EditableField'; // Adjust path if needed
+import EditableTextArea from './../common/EditableTextArea'; // Adjust path if needed
+import FileUploadField from './../common/FileUploadField'; // Adjust path if needed
+
+const ProfileInfoSection = ({
+ professorData,
+ isSaving,
+ handleNameSave,
+ handleHeadlineSave,
+ handlePronounsSave,
+ handleAboutSave,
+ handleResumeSave,
+ handleResumeDelete,
+ handleDepartmentSave, // <-- Destructure the new handler prop
+}) => {
+
+ const resumeLink = professorData?.resumeLink || ''; // Extract for clarity
+
+ return (
+ <Box sx={{ textAlign: 'left', pt: 2, pl: { xs: 0, sm: 2 } }}>
+ {/* --- Render EditableFields --- */}
+ <EditableField
+ label="Full Name"
+ value={professorData?.name}
+ onSave={handleNameSave}
+ typographyVariant="h5"
+ placeholder="(No Name Set)"
+ textFieldProps={{ size: 'small' }}
+ containerSx={{ mb: 0.5, fontWeight: 'bold' }}
+ isSaving={isSaving}
+ />
+ <EditableField
+ label="Headline/Title"
+ value={professorData?.headline}
+ onSave={handleHeadlineSave}
+ typographyVariant="subtitle1"
+ placeholder="(No headline)"
+ emptyText="(No headline)"
+ textFieldProps={{ size: 'small' }}
+ containerSx={{ mb: 0.5, '& .MuiTypography-root': { color: 'text.secondary' } }}
+ isSaving={isSaving}
+ />
+ <EditableField
+ label="Pronouns"
+ value={professorData?.pronouns}
+ onSave={handlePronounsSave}
+ typographyVariant="body2"
+ placeholder="(Not set)"
+ emptyText={`Pronouns: ${professorData?.pronouns || '(Not set)'}`}
+ textFieldProps={{ size: 'small' }}
+ containerSx={{ mb: 2, '& .MuiTypography-root': { color: 'text.secondary' } }}
+ isSaving={isSaving}
+ />
+
+ {/* +++ Add Department Field +++ */}
+ <EditableField
+ label="Department"
+ value={professorData?.department} // Bind to department field
+ onSave={handleDepartmentSave} // Connect to the save handler
+ typographyVariant="body1" // Choose appropriate size
+ placeholder="(e.g., Computer Science)"
+ emptyText={`Department: ${professorData?.department || '(Not set)'}`}
+ textFieldProps={{ size: 'small' }}
+ containerSx={{ mb: 2 }} // Add margin bottom
+ isSaving={isSaving}
+ />
+ {/* +++ End Department Field +++ */}
+
+ {/* --- Render EditableTextArea for About --- */}
+ <Box sx={{ mb: 3 }}>
+ <Typography variant="h6" gutterBottom sx={{ fontWeight: 'medium' }}>About</Typography>
+ <EditableTextArea
+ label="About"
+ value={professorData?.about}
+ onSave={handleAboutSave}
+ placeholder="(Provide a brief description about yourself)"
+ emptyText="(No about section provided)"
+ textFieldProps={{ rows: 4 }}
+ isSaving={isSaving}
+ />
+ </Box>
+
+ {/* --- Render FileUploadField for Resume --- */}
+ <FileUploadField
+ label="Resume"
+ fileLink={resumeLink}
+ accept="application/pdf"
+ onSave={handleResumeSave}
+ onDelete={handleResumeDelete}
+ isSaving={isSaving}
+ viewButtonText="View PDF"
+ selectButtonText="Select PDF File"
+ noFileText="No resume uploaded"
+ containerSx={{ mt: 2 }}
+ />
+ </Box>
+ );
+};
+
+export default ProfileInfoSection; |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 | + + + + + + + + + + + + + + + + + + +1x + +23x +23x + +23x + + + + + + + + +23x +23x +23x +23x + + +23x +5x +5x + +5x + + + +23x +10x + +10x +5x + +5x + +5x + +5x +5x + + + +5x +5x + + + + + + + + +5x +5x + + + +10x + + + +23x + + + + + + + + + + + + + +23x + + + + + + + + + + + + + + + +23x + +2x +1x +1x + +1x + + + + +1x + + +1x + + + + + + + + +1x +1x + + + + + +1x +1x + + + +1x +1x +1x + + + + + + + + + +23x +2x +2x + + + + + + + +23x +5x + + +18x + + + + + + + + + + + +3x +3x +3x +3x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | /* eslint-disable react/prop-types */
+/* eslint-disable no-unused-vars */
+// src/components/profile/StudentCoursesEnrolled.jsx
+import React, { useState, useEffect } from 'react';
+import {
+ Box, Button, Card, CardContent, Typography, Chip, TextField,
+ MenuItem, Select, InputLabel, FormControl, Dialog, DialogActions,
+ DialogContent, DialogTitle, CircularProgress, Alert // Added CircularProgress, Alert
+} from '@mui/material';
+import DeleteIcon from '@mui/icons-material/Delete';
+import AddIcon from '@mui/icons-material/Add';
+import EditIcon from '@mui/icons-material/Edit';
+import { auth, db } from '../../firebase'; // Adjust path if needed
+import {
+ collection, query, addDoc, deleteDoc, updateDoc, doc, onSnapshot, orderBy // Added orderBy
+} from 'firebase/firestore';
+import { onAuthStateChanged } from 'firebase/auth';
+
+// Component Renamed
+const StudentCoursesEnrolled = ({ studentData }) => { // Accept studentData if needed, but we mainly use auth.currentUser
+ // Renamed state for clarity
+ const [enrolledCourses, setEnrolledCourses] = useState([]);
+ const [loading, setLoading] = useState(true);
+ // Updated state for the form fields
+ const [courseEntry, setCourseEntry] = useState({
+ courseCodeName: '', // e.g., "CS 101" or "Intro to Programming"
+ semester: '', // e.g., "Fall 2023"
+ instructorName: '', // Optional instructor
+ status: 'Completed', // Default to Completed? Or Ongoing? Let's use Completed
+ grade: '', // Add grade field
+ // Removed description, link, courseId
+ // Optional: grade: '',
+ });
+ const [user, setUser] = useState(null); // Still useful to trigger useEffect
+ const [isFormVisible, setFormVisible] = useState(false);
+ const [editingCourseId, setEditingCourseId] = useState(null); // Firestore doc ID of the course being edited
+ const [formError, setFormError] = useState(''); // Error state for the form
+
+ // Auth listener (needed to get user ID for queries)
+ useEffect(() => {
+ const unsubscribe = onAuthStateChanged(auth, (currentUser) => {
+ setUser(currentUser);
+ });
+ return () => unsubscribe();
+ }, []);
+
+ // --- UPDATED useEffect to use onSnapshot for Enrolled Courses Subcollection ---
+ useEffect(() => {
+ let unsubscribe = () => {};
+
+ if (user) {
+ setLoading(true);
+ // --- UPDATED PATH: Point to the subcollection ---
+ const enrolledCoursesCollectionRef = collection(db, 'users', user.uid, 'enrolledCourses');
+ // Optional: Order by semester or course code
+ const q = query(enrolledCoursesCollectionRef, orderBy('semester', 'desc')); // Example order
+
+ unsubscribe = onSnapshot(q, (querySnapshot) => {
+ const fetchedCourses = querySnapshot.docs.map((doc) => ({
+ id: doc.id, // This is the unique ID of the document within the subcollection
+ ...doc.data(),
+ }));
+ setEnrolledCourses(fetchedCourses);
+ setLoading(false);
+ }, (error) => {
+ console.error('Error listening to enrolled courses:', error);
+ setLoading(false);
+ // Optionally set an error state
+ });
+
+ } else {
+ // No user, clear courses
+ setEnrolledCourses([]);
+ setLoading(false);
+ }
+
+ // Cleanup listener
+ return () => unsubscribe();
+ }, [user]); // Re-run if user changes
+
+
+ const handleRemoveCourse = async (courseDocId) => {
+ if (!user) return;
+ try {
+ // --- UPDATED PATH ---
+ const courseDocRef = doc(db, 'users', user.uid, 'enrolledCourses', courseDocId);
+ await deleteDoc(courseDocRef);
+ // No need to filter state - onSnapshot handles update
+ } catch (err) {
+ console.error('Error deleting enrolled course:', err);
+ alert('Failed to delete course.'); // Provide feedback
+ }
+ };
+
+
+ const handleEditCourse = (course) => {
+ // Populate form with existing course data
+ setCourseEntry({
+ courseCodeName: course.courseCodeName || '',
+ semester: course.semester || '',
+ instructorName: course.instructorName || '',
+ status: course.status || 'Completed',
+ grade: course.grade || '', // <-- Populate grade field
+ // grade: course.grade || '', // If grade field is added
+ });
+ setEditingCourseId(course.id); // Store the Firestore document ID
+ setFormVisible(true);
+ setFormError(''); // Clear previous form errors
+ };
+
+
+ const handleSaveCourse = async () => {
+ // Validation for required fields
+ if (!courseEntry.courseCodeName || !courseEntry.semester) {
+ setFormError('Course Code/Name and Semester are required.');
+ return;
+ }
+ Iif (!user) {
+ setFormError('Authentication error.');
+ return;
+ }
+
+ setFormError(''); // Clear error
+
+ // Prepare data to save (excluding fields not needed or empty)
+ const dataToSave = {
+ courseCodeName: courseEntry.courseCodeName.trim(),
+ semester: courseEntry.semester.trim(),
+ instructorName: courseEntry.instructorName.trim(), // Save even if empty
+ status: courseEntry.status,
+ grade: (courseEntry.grade || '').trim(),
+ // grade: courseEntry.grade // If grade field is added
+ };
+
+ try {
+ Iif (editingCourseId) {
+ // --- UPDATED PATH for Update ---
+ const courseDocRef = doc(db, 'users', user.uid, 'enrolledCourses', editingCourseId);
+ await updateDoc(courseDocRef, dataToSave);
+ } else {
+ // --- UPDATED PATH for Add ---
+ const enrolledCoursesCollectionRef = collection(db, 'users', user.uid, 'enrolledCourses');
+ await addDoc(enrolledCoursesCollectionRef, dataToSave);
+ }
+
+ // Reset form state and close dialog
+ setCourseEntry({ courseCodeName: '', semester: '', instructorName: '', status: 'Completed', grade: '' });
+ setEditingCourseId(null);
+ setFormVisible(false);
+ // No need to refetch - onSnapshot handles update
+
+ } catch (err) {
+ console.error('Error saving enrolled course:', err);
+ setFormError('Failed to save course. Please try again.'); // Show error in form
+ }
+ };
+
+ // Handle input changes for the form
+ const handleInputChange = (e) => {
+ const { name, value } = e.target;
+ setCourseEntry((prev) => ({
+ ...prev,
+ [name]: value,
+ }));
+ };
+
+
+ // Render Loading state
+ if (loading) {
+ return <Box sx={{ display: 'flex', justifyContent: 'center', p: 3 }}><CircularProgress /></Box>;
+ }
+
+ return (
+ <Box>
+ {/* Title */}
+ <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
+ <Typography variant="h5" gutterBottom component="div"> {/* Changed variant */}
+ My Courses Enrolled
+ </Typography>
+ {/* Add Course Button */}
+ <Button
+ variant="contained"
+ onClick={() => {
+ // Reset form for adding new
+ setCourseEntry({ courseCodeName: '', semester: '', instructorName: '', status: 'Completed' });
+ setEditingCourseId(null);
+ setFormVisible(true);
+ setFormError('');
+ }}
+ startIcon={<AddIcon />}
+ size="small" // Make button smaller
+ >
+ Add Course
+ </Button>
+ </Box>
+
+ {/* Course Cards Container */}
+ <Box display="flex" flexWrap="wrap" gap={2}>
+ {/* No courses message */}
+ {enrolledCourses.length === 0 && (
+ <Typography sx={{width: '100%', textAlign: 'center', color: 'text.secondary', mt: 2}}>
+ You haven't added any courses yet.
+ </Typography>
+ )}
+
+ {/* Mapping through enrolled courses */}
+ {enrolledCourses.map((course) => (
+ <Card key={course.id} sx={{ width: '250px' }}> {/* Unique key is Firestore doc ID */}
+ <CardContent sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}> {/* Flex column */}
+ <Box sx={{ flexGrow: 1 }}> {/* Content takes available space */}
+ <Chip
+ label={course.status || 'Completed'} // Default if missing
+ color={course.status === 'Ongoing' ? 'info' : 'default'} // Use info for ongoing
+ size="small"
+ sx={{mb: 1}}
+ />
+ <Typography variant="h6" gutterBottom sx={{ fontSize: '1rem' }}> {/* Smaller H6 */}
+ {course.courseCodeName || 'No Name'}
+ </Typography>
+ <Typography variant="body2" color="text.secondary">
+ Semester: {course.semester || 'N/A'}
+ </Typography>
+ {course.instructorName && ( // Only display if instructor exists
+ <Typography variant="body2" color="text.secondary">
+ Instructor: {course.instructorName}
+ </Typography>
+ )}
+ {/* +++ Display Grade if available +++ */}
+ {course.grade && (
+ <Typography variant="body2" color="text.secondary">
+ Grade: {course.grade}
+ </Typography>
+ )}
+ {/* +++ End Grade Display +++ */}
+ </Box>
+ {/* Action Buttons pushed to bottom */}
+ <Box sx={{mt: 1, pt: 1, display: 'flex', justifyContent: 'space-between', borderTop: '1px solid #eee'}}>
+ <Button onClick={() => handleEditCourse(course)} color="primary" size="small" startIcon={<EditIcon/>}> Edit </Button>
+ <Button onClick={() => handleRemoveCourse(course.id)} startIcon={<DeleteIcon />} color="error" size="small"> Delete </Button>
+ </Box>
+ </CardContent>
+ </Card>
+ ))}
+ </Box>
+
+ {/* Add/Edit Course Dialog */}
+ <Dialog open={isFormVisible} onClose={() => setFormVisible(false)} maxWidth="xs" fullWidth>
+ <DialogTitle>{editingCourseId ? 'Edit Enrolled Course' : 'Add Enrolled Course'}</DialogTitle>
+ <DialogContent>
+ {/* Display form error if any */}
+ {formError && <Alert severity="error" sx={{ mb: 2 }}>{formError}</Alert>}
+
+ <TextField
+ label="Course Code / Name" // Combined field
+ fullWidth
+ required
+ margin="normal"
+ name="courseCodeName"
+ value={courseEntry.courseCodeName}
+ onChange={handleInputChange}
+ />
+ <TextField
+ label="Semester Taken" // e.g., Fall 2024
+ fullWidth
+ required
+ margin="normal"
+ name="semester"
+ value={courseEntry.semester}
+ onChange={handleInputChange}
+ />
+ <TextField
+ label="Instructor Name (Optional)"
+ fullWidth
+ margin="normal"
+ name="instructorName"
+ value={courseEntry.instructorName}
+ onChange={handleInputChange}
+ />
+ <FormControl fullWidth margin="normal">
+ <InputLabel>Status</InputLabel>
+ <Select
+ label="Status"
+ name="status"
+ value={courseEntry.status}
+ onChange={handleInputChange}
+ >
+ {/* Changed order, maybe 'Completed' is more common */}
+ <MenuItem value="Completed">Completed</MenuItem>
+ <MenuItem value="Ongoing">Ongoing</MenuItem>
+ </Select>
+ </FormControl>
+ {/* +++ Add Grade TextField +++ */}
+ <TextField
+ label="Grade Achieved (Optional)"
+ fullWidth
+ margin="normal"
+ name="grade" // Matches state key
+ value={courseEntry.grade}
+ onChange={handleInputChange}
+ helperText="e.g., A, B+, 3.5"
+ />
+ {/* +++ End Grade TextField +++ */}
+ </DialogContent>
+ <DialogActions>
+ <Button onClick={() => { setFormVisible(false); setFormError(''); }} color="secondary">
+ Cancel
+ </Button>
+ {/* Call handleSaveCourse */}
+ <Button onClick={handleSaveCourse} color="primary">
+ {editingCourseId ? 'Update Course' : 'Add Course'}
+ </Button>
+ </DialogActions>
+ </Dialog>
+ </Box>
+ );
+};
+
+export default StudentCoursesEnrolled; // Renamed export |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 | + + + + + + + + + + + + + + +1x + + + + +1x + + + + + + + + + + + + +1x + + + + + +1x + +12x +12x +12x +12x + + +12x +12x +12x + + +12x +12x +12x + + + +12x +12x +12x + + +12x + + +12x +5x + + + + +12x +5x + + +5x +5x +5x +5x +5x + +5x +5x + + + +5x +5x + + + + + + + + + + + + + +5x + + + + +12x + + + + + + +12x + + + + + + + + +12x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +12x + + + + + + + + + + + + + + + + + + + + + +12x +2x + +2x + + +12x + + + + + +12x + + + + + + + +12x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +12x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +12x + + + + + + + + +12x + +12x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | /* eslint-disable no-unused-vars */
+/* eslint-disable react/prop-types */
+// src/components/profile/StudentExperienceResearch.jsx
+import React, { useState, useEffect } from 'react';
+import { Box, Typography, TextField, Button, Chip, Stack, IconButton, CircularProgress, Alert as MuiAlert, Paper, Divider, Link as MuiLink, Snackbar } from '@mui/material';
+import { doc, updateDoc, arrayUnion, arrayRemove, collection, query, onSnapshot, orderBy, addDoc, deleteDoc } from 'firebase/firestore';
+import { db, auth } from '../../firebase'; // Adjust path if needed
+import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline';
+import CancelIcon from '@mui/icons-material/Cancel';
+import EditIcon from '@mui/icons-material/Edit'; // For Edit button
+import DeleteIcon from '@mui/icons-material/Delete'; // For Delete button
+import AddIcon from '@mui/icons-material/Add'; // For Add Experience button
+import ExperienceForm from './ExperienceForm'; // Assuming it's in the same directory
+
+// --- Define the tag limit ---
+const TAG_LIMIT = 15;
+
+// --- Helper function to format Dates (Optional but Recommended) ---
+// You might want to install date-fns: npm install date-fns
+// import { format } from 'date-fns';
+const formatExperienceDate = (timestamp) => {
+ if (!timestamp?.toDate) return 'N/A';
+ try {
+ // Example format, adjust as needed
+ // return format(timestamp.toDate(), 'MMM yyyy');
+ return timestamp.toDate().toLocaleDateString(undefined, { year: 'numeric', month: 'short'});
+ } catch (e) {
+ return 'Invalid Date';
+ }
+};
+// --- End Date Helper ---
+
+// --- Snackbar Alert ForwardRef ---
+const Alert = React.forwardRef(function Alert(props, ref) {
+ return <MuiAlert elevation={6} ref={ref} variant="filled" {...props} />;
+});
+// --- End Snackbar Alert ---
+
+
+const StudentExperienceResearch = ({ studentData }) => {
+ // State for Tags
+ const [newTag, setNewTag] = useState('');
+ const [displayTags, setDisplayTags] = useState(studentData?.experienceTags || []);
+ const [isProcessingTag, setIsProcessingTag] = useState(false); // Renamed for clarity
+ const [tagError, setTagError] = useState('');
+
+ // State for Structured Experiences
+ const [experiences, setExperiences] = useState([]);
+ const [loadingExperiences, setLoadingExperiences] = useState(true);
+ const [experienceError, setExperienceError] = useState(null); // Error during fetch
+
+ // --- State for Experience Modal ---
+ const [isExperienceModalOpen, setIsExperienceModalOpen] = useState(false);
+ const [editingExperienceData, setEditingExperienceData] = useState(null); // null for add, object for edit
+ const [isSavingExperience, setIsSavingExperience] = useState(false); // Loading state for save/update/delete
+ // const [crudError, setCrudError] = useState(null); // Error during save/update/delete
+
+ // --- Snackbar State ---
+ const [snackbarOpen, setSnackbarOpen] = useState(false);
+ const [snackbarMessage, setSnackbarMessage] = useState('');
+ const [snackbarSeverity, setSnackbarSeverity] = useState('info');
+
+ // Use authenticated user's ID
+ const userId = auth.currentUser?.uid; // Define userId here
+
+ // Update local state if the prop changes (e.g., parent re-fetches)
+ useEffect(() => {
+ setDisplayTags(studentData?.experienceTags || []);
+ }, [studentData?.experienceTags]);
+
+
+ // --- Effect for fetching STRUCTURED experiences ---
+ useEffect(() => {
+ let unsubscribe = () => {};
+ // const currentUser = auth.currentUser; // Use userId defined above
+
+ if (userId) { // Use userId
+ setLoadingExperiences(true);
+ setExperienceError(null);
+ const experiencesCollectionRef = collection(db, 'users', userId, 'experiences'); // Use userId
+ const q = query(experiencesCollectionRef, orderBy('startDate', 'desc'));
+
+ unsubscribe = onSnapshot(q, (querySnapshot) => {
+ const fetchedExperiences = querySnapshot.docs.map(doc => ({
+ id: doc.id,
+ ...doc.data()
+ }));
+ setExperiences(fetchedExperiences);
+ setLoadingExperiences(false);
+ }, (error) => {
+ console.error("Error listening to experiences:", error);
+ setExperienceError("Failed to load experiences.");
+ setLoadingExperiences(false);
+ });
+
+ } else E{
+ setExperiences([]);
+ setLoadingExperiences(false);
+ // Consider setting an error if user should be logged in
+ // setExperienceError("Please log in to manage experiences.");
+ }
+ // Cleanup listener
+ return () => unsubscribe();
+ }, [userId]); // Depend on userId
+
+
+ // --- Snackbar Handler (NEW) ---
+ const handleSnackbarClose = (event, reason) => {
+ if (reason === 'clickaway') {
+ return;
+ }
+ setSnackbarOpen(false);
+ };
+
+ const showSnackbar = (message, severity = 'info') => {
+ setSnackbarMessage(message);
+ setSnackbarSeverity(severity);
+ setSnackbarOpen(true);
+ };
+ // --- End Snackbar Handler ---
+
+
+ // --- Tag Handlers (MODIFIED for Snackbar) ---
+ const handleAddTag = async () => {
+ const tagToAdd = newTag.trim();
+ setTagError(''); // Keep inline tag error
+ if (!tagToAdd) { setTagError('Tag cannot be empty.'); return; }
+ if (displayTags.some(tag => tag.toLowerCase() === tagToAdd.toLowerCase())) {
+ setTagError(`Tag "${tagToAdd}" already exists.`); setNewTag(''); return;
+ }
+ if (displayTags.length >= TAG_LIMIT) {
+ setTagError(`You have reached the maximum limit of ${TAG_LIMIT} tags.`); setNewTag(''); return;
+ }
+ // Use userId defined earlier
+ if (!userId) { showSnackbar("Not authenticated.", "error"); return; }
+
+ setIsProcessingTag(true);
+ const userDocRef = doc(db, 'users', userId); // Use userId
+ try {
+ await updateDoc(userDocRef, { experienceTags: arrayUnion(tagToAdd) });
+ setNewTag('');
+ // Optionally show success snackbar
+ showSnackbar(`Tag "${tagToAdd}" added!`, 'success');
+ } catch (err) {
+ console.error("Error adding tag:", err);
+ // Use snackbar for error feedback
+ showSnackbar("Failed to add tag. Please try again.", "error");
+ } finally {
+ setIsProcessingTag(false);
+ }
+ };
+
+
+ const handleRemoveTag = async (tagToRemove) => {
+ // Use userId defined earlier
+ if (!userId) { showSnackbar("Not authenticated.", "error"); return; }
+ if (isProcessingTag) return;
+
+ setIsProcessingTag(true); setTagError(''); // Clear inline error on attempt
+ const userDocRef = doc(db, 'users', userId); // Use userId
+ try {
+ await updateDoc(userDocRef, { experienceTags: arrayRemove(tagToRemove) });
+ // Optionally show success snackbar
+ showSnackbar(`Tag "${tagToRemove}" removed.`, 'success');
+ } catch (err) {
+ console.error("Error removing tag:", err);
+ // Use snackbar for error feedback
+ showSnackbar("Failed to remove tag. Please try again.", "error");
+ } finally {
+ setIsProcessingTag(false);
+ }
+ };
+
+
+ // --- Experience Modal Handlers ---
+ const handleOpenAddExperienceModal = () => {
+ setEditingExperienceData(null); // Clear any previous edit data
+ // setCrudError(null); // Clear previous errors
+ setIsExperienceModalOpen(true);
+ };
+
+ const handleOpenEditExperienceModal = (experienceData) => {
+ setEditingExperienceData(experienceData); // Set data for editing
+ // setCrudError(null); // Clear previous errors
+ setIsExperienceModalOpen(true);
+ };
+
+ const handleCloseExperienceModal = () => {
+ setIsExperienceModalOpen(false);
+ // Optionally clear editing data after close animation:
+ setTimeout(() => setEditingExperienceData(null), 300);
+ };
+
+
+ // --- Experience CRUD Handlers (MODIFIED for Snackbar) ---
+ const handleSaveExperience = async (formData, experienceId) => {
+ // const currentUser = auth.currentUser; // Use userId defined earlier
+ if (!userId) { // Use userId
+ showSnackbar("Authentication error. Cannot save.", "error");
+ throw new Error("User not authenticated.");
+ }
+
+ setIsSavingExperience(true);
+ // setCrudError(null); // Remove if not using crudError state
+
+ try {
+ let message = '';
+ if (experienceId) { // Update
+ const experienceDocRef = doc(db, 'users', userId, 'experiences', experienceId);
+ await updateDoc(experienceDocRef, formData);
+ message = 'Experience updated successfully!';
+ } else { // Add
+ const experiencesCollectionRef = collection(db, 'users', userId, 'experiences');
+ await addDoc(experiencesCollectionRef, formData);
+ message = 'Experience added successfully!';
+ }
+ handleCloseExperienceModal();
+ showSnackbar(message, 'success'); // Show success message
+ } catch (err) {
+ console.error("Error saving experience:", err);
+ showSnackbar(`Failed to save experience: ${err.message}`, 'error'); // Show error message
+ throw err; // Re-throw so ExperienceForm can also catch it
+ } finally {
+ setIsSavingExperience(false);
+ }
+ };
+
+
+ const handleDeleteExperience = async (experienceId) => {
+ // const currentUser = auth.currentUser; // Use userId defined earlier
+ if (!userId) { // Use userId
+ showSnackbar("Authentication error. Cannot delete.", "error");
+ return;
+ }
+ if (isSavingExperience) return;
+
+ if (!window.confirm("Are you sure you want to delete this experience entry? This action cannot be undone.")) {
+ return;
+ }
+
+ setIsSavingExperience(true);
+ // setCrudError(null); // Remove if not using crudError state
+
+ try {
+ const experienceDocRef = doc(db, 'users', userId, 'experiences', experienceId);
+ await deleteDoc(experienceDocRef);
+ console.log("Experience deleted successfully:", experienceId);
+ showSnackbar("Experience deleted successfully.", 'success'); // Show success message
+ } catch (err) {
+ console.error("Error deleting experience:", err);
+ showSnackbar(`Failed to delete experience: ${err.message}`, 'error'); // Show error message
+ } finally {
+ setIsSavingExperience(false);
+ }
+ };
+ // --- End Experience CRUD Handlers ---
+
+
+ // Group experiences by type for rendering
+ const groupedExperiences = experiences.reduce((acc, exp) => {
+ const type = exp.type || 'other'; // Group undefined types as 'other'
+ if (!acc[type]) { acc[type] = []; }
+ acc[type].push(exp);
+ return acc;
+ }, {});
+
+
+ // Calculate if the limit is reached to disable input/button
+ const limitReached = displayTags.length >= TAG_LIMIT;
+
+ return (
+ <Box sx={{p:3}}>
+ {/* --- Tags Section --- */}
+ {/* --- Tags Section (Keep as is, uses tagError state for inline feedback) --- */}
+ <Typography variant="h5" gutterBottom> My Experience & Research Interests </Typography>
+ <Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}> Add relevant keywords, skills, or research. Max {TAG_LIMIT}. </Typography>
+ <Box component="form" onSubmit={(e) => { e.preventDefault(); handleAddTag(); }} sx={{ display: 'flex', gap: 1, alignItems: 'center', mb: 3 }}>
+ <TextField label={limitReached ? `Tag limit (${TAG_LIMIT}) reached` : "Add New Tag"} variant="outlined" size="small" value={newTag} onChange={(e) => setNewTag(e.target.value)} disabled={isProcessingTag || limitReached} sx={{ flexGrow: 1 }} />
+ <Button type="submit" variant="contained" startIcon={isProcessingTag ? <CircularProgress size={20} color="inherit" /> : <AddCircleOutlineIcon />} disabled={isProcessingTag || !newTag.trim() || limitReached}> Add </Button>
+ </Box>
+ {tagError && <MuiAlert severity="warning" sx={{ mb: 2 }}>{tagError}</MuiAlert>} {/* Keep inline tag error */}
+ <Typography variant="h6" gutterBottom sx={{ fontWeight: 'medium' }}> Tags ({displayTags.length}/{TAG_LIMIT}): </Typography>
+ {displayTags.length > 0 ? ( <Stack direction="row" flexWrap="wrap" spacing={1} useFlexGap> {displayTags.map((tag) => ( <Chip key={tag} label={tag} onDelete={isProcessingTag ? undefined : () => handleRemoveTag(tag)} deleteIcon={<CancelIcon />} disabled={isProcessingTag} /> ))} </Stack> ) : ( <Typography variant="body2" color="text.secondary"> No tags added yet. </Typography> )}
+
+ <Divider sx={{ my: 4 }} />
+
+ {/* --- Structured Experience Section --- */}
+ <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
+ <Typography variant="h5" gutterBottom component="div">Experiences</Typography>
+ <Button variant="contained" startIcon={<AddIcon />} onClick={handleOpenAddExperienceModal} size="small" disabled={isSavingExperience}> Add Experience </Button>
+ </Box>
+
+ {/* Display fetch errors */}
+ {experienceError && !loadingExperiences && <MuiAlert severity="error" sx={{ mb: 2 }}>{experienceError}</MuiAlert>} {/* Keep fetch error */}
+
+ {/* Loading and Empty States (Keep as is) */}
+ {loadingExperiences && <Box sx={{ display: 'flex', justifyContent: 'center', my: 3}}><CircularProgress size={24} /></Box>}
+ {!loadingExperiences && !experienceError && experiences.length === 0 && ( <Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center', mt: 2 }}> No detailed experiences added yet. Click "Add Experience" to get started. </Typography> )}
+
+ {/* Render Experience Groups */}
+ {!loadingExperiences && !experienceError && experiences.length > 0 && (
+ <Stack spacing={4} sx={{ mt: 2 }}>
+ {Object.entries(groupedExperiences).map(([type, exps]) => (
+ <Box key={type}>
+ <Typography variant="h6" gutterBottom sx={{ fontWeight: 'medium', textTransform: 'capitalize' }}>
+ {/* Simple title based on type */}
+ {type === 'work' ? 'Work Experience' :
+ type === 'research' ? 'Research Experience' :
+ type === 'project' ? 'Projects' :
+ type === 'volunteer' ? 'Volunteer Experience' :
+ 'Other Experience' }
+ </Typography>
+ <Stack spacing={2}>
+ {exps.map(exp => (
+ <Paper key={exp.id} variant="outlined" sx={{ p: 2, position: 'relative' }}>
+ {/* Add overlay if deleting this specific item? Maybe too complex for now */}
+ {/* {isSavingExperience && editingExperienceData?.id === exp.id && <CircularProgress size={16} sx={{position: 'absolute', top: 8, right: 8}}/> } */}
+ <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 1 }}>
+ <Box>
+ <Typography sx={{ fontWeight: 'bold' }}>{exp.title || 'N/A'}</Typography>
+ <Typography variant="body2" color="text.secondary">{exp.organization || 'N/A'}</Typography>
+ <Typography variant="caption" color="text.secondary">
+ {formatExperienceDate(exp.startDate)} - {exp.isCurrent ? 'Present' : formatExperienceDate(exp.endDate)}
+ </Typography>
+ </Box>
+ {/* Action Buttons */}
+ <Box sx={{ display: 'flex', gap: 0.5 }}>
+ <IconButton size="small" onClick={() => handleOpenEditExperienceModal(exp)} disabled={isSavingExperience} aria-label="Edit experience">
+ <EditIcon fontSize='small'/>
+ </IconButton>
+ <IconButton size="small" onClick={() => handleDeleteExperience(exp.id)} disabled={isSavingExperience} color="error" aria-label="Delete experience">
+ <DeleteIcon fontSize='small' />
+ </IconButton>
+ </Box>
+ </Box>
+ {exp.description && <Typography variant="body2" sx={{ mt: 1, whiteSpace: 'pre-wrap' }}>{exp.description}</Typography>}
+ {exp.link && <MuiLink href={exp.link} target="_blank" rel="noopener noreferrer" variant="caption" sx={{ display: 'block', mt: 0.5 }}>Visit Link</MuiLink>}
+ </Paper>
+ ))}
+ </Stack>
+ </Box>
+ ))}
+ </Stack>
+ )}
+
+ {/* --- Render ExperienceForm Modal --- */}
+ {/* Render only when needed, or always and rely on 'open' prop */}
+ <ExperienceForm
+ open={isExperienceModalOpen}
+ onClose={handleCloseExperienceModal}
+ onSave={handleSaveExperience} // This function handles Firestore add/update
+ initialData={editingExperienceData}
+ userId={userId} // Pass current user's ID
+ isSaving={isSavingExperience} // Pass saving state to disable form controls
+ />
+
+ {/* --- Snackbar Component (NEW) --- */}
+ <Snackbar open={snackbarOpen} autoHideDuration={5000} onClose={handleSnackbarClose} anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}>
+ <Alert onClose={handleSnackbarClose} severity={snackbarSeverity} sx={{ width: '100%' }}>
+ {snackbarMessage}
+ </Alert>
+ </Snackbar>
+ {/* --- End Snackbar --- */}
+
+ </Box>
+ );
+};
+
+export default StudentExperienceResearch; |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 | + + + + + + + + + + +1x + + + + + + + + + + +10x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +1x + + + + + + + + + + + + | /* eslint-disable no-unused-vars */
+// frontend/src/components/common/ConfirmationDialog.jsx
+import React from 'react';
+import Button from '@mui/material/Button';
+import Dialog from '@mui/material/Dialog';
+import DialogActions from '@mui/material/DialogActions';
+import DialogContent from '@mui/material/DialogContent';
+import DialogContentText from '@mui/material/DialogContentText';
+import DialogTitle from '@mui/material/DialogTitle';
+import PropTypes from 'prop-types';
+
+const ConfirmationDialog = ({
+ open,
+ onClose,
+ onConfirm,
+ title = "Confirm Action", // Default title
+ message = "Are you sure you want to proceed? This action cannot be undone.", // Default message
+ confirmText = "Confirm", // Default confirm button text
+ cancelText = "Cancel", // Default cancel button text
+ isProcessing = false // Optional: disable buttons while processing confirm action
+}) => {
+
+ return (
+ <Dialog
+ open={open}
+ onClose={() => !isProcessing && onClose()} // Prevent closing while processing
+ aria-labelledby="confirmation-dialog-title"
+ aria-describedby="confirmation-dialog-description"
+ >
+ <DialogTitle id="confirmation-dialog-title">
+ {title}
+ </DialogTitle>
+ <DialogContent>
+ <DialogContentText id="confirmation-dialog-description">
+ {message}
+ </DialogContentText>
+ </DialogContent>
+ <DialogActions>
+ <Button onClick={onClose} disabled={isProcessing}>
+ {cancelText}
+ </Button>
+ {/* Make confirm button stand out, often uses primary or error color */}
+ <Button
+ onClick={onConfirm}
+ color="primary" // Or maybe "error" for delete actions
+ variant="contained" // Make it more prominent
+ autoFocus // Focus on confirm by default
+ disabled={isProcessing}
+ >
+ {isProcessing ? "Processing..." : confirmText}
+ </Button>
+ </DialogActions>
+ </Dialog>
+ );
+};
+
+ConfirmationDialog.propTypes = {
+ open: PropTypes.bool.isRequired,
+ onClose: PropTypes.func.isRequired,
+ onConfirm: PropTypes.func.isRequired,
+ title: PropTypes.string,
+ message: PropTypes.string,
+ confirmText: PropTypes.string,
+ cancelText: PropTypes.string,
+ isProcessing: PropTypes.bool
+};
+
+
+export default ConfirmationDialog; |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 | + + + + + + + + +1x + + + + + + + + + + + + +16x +16x + +16x +16x + + +16x + + + + +16x + + + + +16x + + + + + + + + +16x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | /* eslint-disable react/prop-types */
+/* eslint-disable no-unused-vars */
+// frontend/src/components/common/EditableField.jsx
+import React, { useState, useEffect } from 'react';
+import { Box, Typography, TextField, IconButton } from '@mui/material';
+import EditIcon from '@mui/icons-material/Edit';
+import CheckIcon from '@mui/icons-material/Check';
+import CloseIcon from '@mui/icons-material/Close';
+
+const EditableField = ({
+ label, // Label for the TextField in edit mode AND display mode now
+ value, // The current value to display
+ onSave, // Async function to call when saving (receives new value)
+ typographyVariant = "body1", // MUI Typography variant for display mode VALUE
+ placeholder = "", // Placeholder text (less relevant with label above)
+ emptyText = "(Not set)", // Text to show when value is empty
+ textFieldProps = {}, // Additional props for the TextField
+ containerSx = {}, // Custom styles for the main container Box
+ isSaving = false, // Optional: Prop to disable buttons during save
+ multiline = false, // Optional: Use multiline TextField (though TextArea component preferred for this)
+ rows = 1 // Optional: Rows for multiline TextField
+}) => {
+ const [isEditing, setIsEditing] = useState(false);
+ const [inputValue, setInputValue] = useState(value || '');
+
+ useEffect(() => {
+ setInputValue(value || '');
+ }, [value]);
+
+ const handleEditClick = () => {
+ setInputValue(value || '');
+ setIsEditing(true);
+ };
+
+ const handleCancelClick = () => {
+ setIsEditing(false);
+ setInputValue(value || '');
+ };
+
+ const handleSaveClick = async () => {
+ try {
+ await onSave(inputValue.trim());
+ setIsEditing(false);
+ } catch (error) {
+ console.error(`Error saving ${label}:`, error);
+ }
+ };
+
+ return (
+ // Container - align items to the start (top) for label
+ <Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, minHeight: '40px', width: '100%', ...containerSx }}>
+ {!isEditing ? (
+ // --- MODIFIED Display Mode ---
+ <>
+ <Box sx={{ flexGrow: 1 }}> {/* Box to hold label and value */}
+ {/* Display Label */}
+ <Typography
+ variant="caption" // Smaller variant for label
+ component="div"
+ sx={{ color: 'text.secondary', lineHeight: 1.2 }} // Adjust line height if needed
+ >
+ {label}
+ </Typography>
+ {/* Display Value */}
+ <Typography
+ variant={typographyVariant}
+ sx={{
+ color: !value ? 'text.secondary' : 'inherit',
+ // Apply word break for safety, although less likely needed for single-line fields
+ overflowWrap: 'break-word',
+ lineHeight: 1.4 // Adjust line height if needed
+ }}
+ >
+ {/* Use value, fallback to emptyText. Placeholder less relevant here */}
+ {value || emptyText}
+ </Typography>
+ </Box>
+ {/* Edit Button */}
+ <IconButton size="small" onClick={handleEditClick} aria-label={`Edit ${label}`} disabled={isSaving} sx={{mt: 0.5 /* Adjust vertical alignment if needed */}}>
+ <EditIcon fontSize="small" />
+ </IconButton>
+ </>
+ // --- END MODIFIED Display Mode ---
+ ) : (
+ // --- Edit Mode (No Change Needed Here) ---
+ <>
+ <TextField
+ label={label}
+ value={inputValue}
+ onChange={(e) => setInputValue(e.target.value)}
+ variant="outlined"
+ fullWidth
+ autoFocus
+ multiline={multiline}
+ rows={multiline ? rows : 1}
+ {...textFieldProps}
+ disabled={isSaving}
+ />
+ <IconButton size="small" onClick={handleSaveClick} color="success" aria-label={`Save ${label}`} disabled={isSaving}>
+ <CheckIcon />
+ </IconButton>
+ <IconButton size="small" onClick={handleCancelClick} color="error" aria-label={`Cancel ${label} edit`} disabled={isSaving}>
+ <CloseIcon />
+ </IconButton>
+ </>
+ )}
+ </Box>
+ );
+};
+
+export default EditableField; |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 | + + + + + + + + +1x + + + + + + + + + + + +4x +4x + +4x +4x + + +4x + + + + +4x + + + + +4x + + + + + + + + +4x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | /* eslint-disable no-unused-vars */
+/* eslint-disable react/prop-types */
+// frontend/src/components/common/EditableTextArea.jsx
+import React, { useState, useEffect } from 'react';
+import { Box, Typography, TextField, IconButton } from '@mui/material';
+import EditIcon from '@mui/icons-material/Edit';
+import CheckIcon from '@mui/icons-material/Check';
+import CloseIcon from '@mui/icons-material/Close';
+
+const EditableTextArea = ({
+ label, // Label for the TextField in edit mode AND display mode now
+ value, // The current value to display
+ onSave, // Async function to call when saving (receives new value)
+ typographyVariant = "body2", // MUI Typography variant for display mode VALUE
+ placeholder = "(Not provided)", // Placeholder text when value is empty
+ emptyText = "(Not provided)", // Text to show when value is truly empty
+ textFieldProps = {}, // Additional props for the TextField
+ containerSx = {}, // Custom styles for the main container Box
+ isSaving = false, // Optional: Prop to disable buttons during save
+ rows = 4 // Default rows for the text area
+}) => {
+ const [isEditing, setIsEditing] = useState(false);
+ const [inputValue, setInputValue] = useState(value || '');
+
+ useEffect(() => {
+ setInputValue(value || '');
+ }, [value]);
+
+ const handleEditClick = () => {
+ setInputValue(value || '');
+ setIsEditing(true);
+ };
+
+ const handleCancelClick = () => {
+ setIsEditing(false);
+ setInputValue(value || '');
+ };
+
+ const handleSaveClick = async () => {
+ try {
+ await onSave(inputValue.trim());
+ setIsEditing(false);
+ } catch (error) {
+ console.error(`Error saving ${label}:`, error);
+ }
+ };
+
+ return (
+ // Container remains largely the same
+ <Box sx={{ width: '100%', position: 'relative', ...containerSx }}>
+ {!isEditing ? (
+ // Display Mode
+ <Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1 }}>
+ <Box sx={{ flexGrow: 1 }}>
+ <Typography variant="caption" component="div" sx={{ color: 'text.secondary', mb: 0.25 }}>
+ {label}
+ </Typography>
+ <Typography
+ variant={typographyVariant} // This variant is passed as a prop
+ sx={{
+ whiteSpace: 'pre-wrap', // Keep line breaks
+ // --- MODIFY WORD BREAKING ---
+ // overflowWrap: 'break-word', // Use this instead of break-all
+ wordBreak: 'break-word', // Alternative to overflow-wrap
+ hyphens: 'auto', // <<< ADD THIS FOR HYPHENATION
+ // --- END MODIFICATION ---
+ color: !value ? 'text.secondary' : 'inherit',
+ minHeight: '20px'
+ }}
+ >
+ {value?.trim() || placeholder || emptyText}
+ </Typography>
+ </Box>
+ <IconButton size="small" onClick={handleEditClick} /* ... other props ... */ sx={{ mt: 1 }}>
+ <EditIcon fontSize="small" />
+ </IconButton>
+ </Box>
+ // --- END MODIFIED Display Mode ---
+ ) : (
+ // --- Edit Mode (No Change Needed Here) ---
+ <>
+ <TextField
+ label={label}
+ value={inputValue}
+ onChange={(e) => setInputValue(e.target.value)}
+ variant="outlined"
+ fullWidth
+ multiline
+ rows={rows}
+ autoFocus
+ {...textFieldProps}
+ disabled={isSaving}
+ />
+ <Box sx={{ display: 'flex', gap: 1, mt: 1 }}>
+ <IconButton size="small" onClick={handleSaveClick} color="success" aria-label={`Save ${label}`} disabled={isSaving}>
+ <CheckIcon />
+ </IconButton>
+ <IconButton size="small" onClick={handleCancelClick} color="error" aria-label={`Cancel ${label} edit`} disabled={isSaving}>
+ <CloseIcon />
+ </IconButton>
+ </Box>
+ </>
+ )}
+ </Box>
+ );
+};
+
+export default EditableTextArea; |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 | + + + + + + + + + + +1x + + + + + + + + + + + +4x +4x +4x + +4x + + + + + + + +4x + + + + + + + +4x + + + + + + + +4x + + + + + + + + + + + + + + +4x + + + + + + + + + + + + + +4x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | /* eslint-disable react/prop-types */
+/* eslint-disable no-unused-vars */
+// frontend/src/components/common/FileUploadField.jsx
+import React, { useState, useRef } from 'react';
+import { Box, Typography, Button, IconButton } from '@mui/material';
+import EditIcon from '@mui/icons-material/Edit';
+import DeleteIcon from '@mui/icons-material/Delete';
+import CheckIcon from '@mui/icons-material/Check';
+import CloseIcon from '@mui/icons-material/Close';
+import UploadFileIcon from '@mui/icons-material/UploadFile'; // Optional: Icon for button
+
+const FileUploadField = ({
+ label, // Title for the section (e.g., "Resume")
+ fileLink, // URL of the currently uploaded file (if any)
+ accept, // File type string for the input (e.g., "application/pdf")
+ onSave, // Async function to call when saving (receives file object)
+ onDelete, // Async function to call when deleting
+ isSaving = false, // Prop to disable buttons during save/delete
+ viewButtonText = "View File",
+ selectButtonText = "Select File",
+ noFileText = "No file uploaded",
+ containerSx = {}
+}) => {
+ const [isEditing, setIsEditing] = useState(false);
+ const [selectedFile, setSelectedFile] = useState(null);
+ const fileInputRef = useRef(null); // To potentially clear the input
+
+ const handleEditClick = () => {
+ setSelectedFile(null); // Clear previous selection when entering edit mode
+ if (fileInputRef.current) {
+ fileInputRef.current.value = ""; // Attempt to clear the actual file input
+ }
+ setIsEditing(true);
+ };
+
+ const handleCancelClick = () => {
+ setIsEditing(false);
+ setSelectedFile(null);
+ if (fileInputRef.current) {
+ fileInputRef.current.value = "";
+ }
+ };
+
+ const handleFileChange = (e) => {
+ if (e.target.files && e.target.files[0]) {
+ setSelectedFile(e.target.files[0]);
+ } else {
+ setSelectedFile(null);
+ }
+ };
+
+ const handleSaveClick = async () => {
+ if (!selectedFile) return;
+ try {
+ await onSave(selectedFile); // Pass the file object to the parent's save function
+ setIsEditing(false); // Exit edit mode on success
+ setSelectedFile(null); // Clear selection
+ if (fileInputRef.current) {
+ fileInputRef.current.value = "";
+ }
+ } catch (error) {
+ console.error(`Error saving ${label}:`, error);
+ // Error handling (e.g., alert) should ideally be in the parent's onSave
+ }
+ };
+
+ const handleDeleteClick = async () => {
+ if (!fileLink) return; // Should not happen if delete icon isn't shown, but good practice
+ // Optional: Add confirmation dialog here
+ if (window.confirm(`Are you sure you want to delete the ${label}?`)) {
+ try {
+ await onDelete(); // Call parent's delete function
+ // State updates should happen in the parent via professorData change
+ } catch (error) {
+ console.error(`Error deleting ${label}:`, error);
+ // Error handling should ideally be in the parent's onDelete
+ }
+ }
+ };
+
+ return (
+ <Box sx={{ mt: 2, ...containerSx }}>
+ <Typography variant="h6" gutterBottom sx={{ fontWeight: 'medium' }}>{label}</Typography>
+ {!isEditing ? (
+ // Display Mode
+ <Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
+ {fileLink ? (
+ <Button
+ variant="outlined" component="a" href={fileLink}
+ target="_blank" rel="noopener noreferrer"
+ sx={{ textTransform: 'none' }}
+ size="small"
+ >
+ {viewButtonText}
+ </Button>
+ ) : (
+ <Typography variant="body2" color="text.secondary">{noFileText}</Typography>
+ )}
+ <IconButton size="small" onClick={handleEditClick} aria-label={`Edit ${label}`} disabled={isSaving}>
+ <EditIcon fontSize="small" />
+ </IconButton>
+ {fileLink && (
+ <IconButton size="small" onClick={handleDeleteClick} color="error" aria-label={`Delete ${label}`} disabled={isSaving}>
+ <DeleteIcon fontSize="small" />
+ </IconButton>
+ )}
+ </Box>
+ ) : (
+ // Edit Mode
+ <Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, mt: 1, maxWidth: 300 }}>
+ <Button
+ variant="outlined" component="label" size="small"
+ startIcon={<UploadFileIcon />}
+ disabled={isSaving}
+ >
+ {selectedFile ? `Selected: ${selectedFile.name}` : selectButtonText}
+ <input
+ ref={fileInputRef}
+ type="file" accept={accept} hidden
+ onChange={handleFileChange}
+ disabled={isSaving}
+ />
+ </Button>
+ <Box sx={{ display: 'flex', gap: 1 }}>
+ <Button onClick={handleSaveClick} color="success" size="small" variant="contained" disabled={!selectedFile || isSaving}>
+ Save
+ </Button>
+ <Button onClick={handleCancelClick} color="inherit" size="small" variant="outlined" disabled={isSaving}>
+ Cancel
+ </Button>
+ </Box>
+ </Box>
+ )}
+ </Box>
+ );
+};
+
+export default FileUploadField; |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| EditableField.jsx | +
+
+ |
+ 50% | +9/18 | +50% | +12/24 | +33.33% | +2/6 | +50% | +9/18 | +
| EditableTextArea.jsx | +
+
+ |
+ 50% | +9/18 | +54.54% | +12/22 | +33.33% | +2/6 | +50% | +9/18 | +
| FileUploadField.jsx | +
+
+ |
+ 27.77% | +10/36 | +16.12% | +5/31 | +16.66% | +1/6 | +29.41% | +10/34 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +