diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c68dfae --- /dev/null +++ b/Dockerfile @@ -0,0 +1,39 @@ +# Use Node.js 18 Alpine as base image +FROM node:18-alpine + +# Set working directory +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install dependencies +RUN npm install --legacy-peer-deps + +# Copy application code +COPY . . + +# Create config directory and copy config template +RUN mkdir -p /app/config +COPY config.json /app/config.json + +# Build the application +RUN npm run build + +# Set default hostname if not provided +ENV HOSTNAME=container +ENV NEXT_PUBLIC_HOSTNAME=container + +# Expose port +EXPOSE 3000 + +# Create non-root user +RUN addgroup -g 1001 -S nodejs +RUN adduser -S nextjs -u 1001 + +# Change ownership of the app directory +RUN chown -R nextjs:nodejs /app +USER nextjs + +# Start the application +CMD ["npm", "start"] diff --git a/app/admin/page.tsx b/app/admin/page.tsx new file mode 100644 index 0000000..0e11afa --- /dev/null +++ b/app/admin/page.tsx @@ -0,0 +1,1380 @@ +"use client" + +import type React from "react" +import { useState, useEffect } from "react" +import { useRouter } from "next/navigation" +// app/admin/page.tsx + +// ... (other imports) + +// Updated import to get all necessary types and constants from types/user.ts +import { + type User, + type UserRole, + type Field, + type Department, + type Team, + ROLE_NAMES, + SHELTER_STATUS_NAMES, // You might need this if displaying status names + DEPARTMENTS, // If you use this array anywhere for dropdowns/validation + TEAMS, // If you use this array anywhere for dropdowns/validation + FIELDS, // If you use this array anywhere for dropdowns/validation +} from "@/types/user" + +// ... (rest of your component code) + +import { + AlertDialog, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} from "@/components/ui/alert-dialog" + +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Checkbox } from "@/components/ui/checkbox" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Alert, AlertDescription } from "@/components/ui/alert" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { + ArrowRight, + RotateCcw, + Users, + UserPlus, + Clock, + Trash2, + Eye, + KeyRound, + RefreshCw, + WifiOff, + Zap, + BarChart3, + PieChart, + UsersIcon, + Globe, + Building2, + UserCog, + MessageSquare, + Lock, + LockOpen, + ArrowLeft, + Home, +} from "lucide-react" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { UserCategoryModal } from "@/components/user-category-modal" +import { TeamUserCategoryModal } from "@/components/team-user-category-modal" +import { StatsPieChart } from "@/components/stats-pie-chart" +import { SimplePieChart } from "@/components/simple-pie-chart" +import { useRealTimeUpdates } from "@/hooks/useRealTimeUpdates" +import { useTeamRealTimeUpdates } from "@/hooks/useTeamRealTimeUpdates" +import { DepartmentUserCategoryModal } from "@/components/department-user-category-modal" +import { useDepartmentRealTimeUpdates } from "@/hooks/useDepartmentRealTimeUpdates" +import { FieldUserCategoryModal } from "@/components/field-user-category-modal" +import { useFieldRealTimeUpdates } from "@/hooks/useFieldRealTimeUpdates" +import { ReportOnBehalfModal } from "@/components/report-on-behalf-modal" + +interface Stats { + no_report: number + in_shelter: number + not_in_shelter: number + no_alarm: number + safe_after_exit: number +} + +interface UserData { + national_id: string + name: string + in_shelter?: string + last_updated?: string + is_admin: boolean + must_change_password?: boolean + field?: string + department?: string + team?: string + lock_status?: boolean +} + +export default function AdminPage() { + const [user, setUser] = useState(null) + const [activeTab, setActiveTab] = useState("team") + + // Global stats and data + const [globalStats, setGlobalStats] = useState(null) + const [globalUsers, setGlobalUsers] = useState([]) + const [globalLastReset, setGlobalLastReset] = useState(null) + const [globalResetCooldown, setGlobalResetCooldown] = useState(0) + + // Team stats and data + const [teamStats, setTeamStats] = useState(null) + const [teamUsers, setTeamUsers] = useState([]) + const [teamName, setTeamName] = useState("") + const [teamResetCooldown, setTeamResetCooldown] = useState(0) + + // Department stats and data + const [departmentStats, setDepartmentStats] = useState(null) + const [departmentUsers, setDepartmentUsers] = useState([]) + const [departmentName, setDepartmentName] = useState("") + const [departmentResetCooldown, setDepartmentResetCooldown] = useState(0) + const [departmentChangedRows, setDepartmentChangedRows] = useState>(new Set()) + const [departmentModalOpen, setDepartmentModalOpen] = useState(false) + + // Field stats and data + const [fieldStats, setFieldStats] = useState(null) + const [fieldUsers, setFieldUsers] = useState([]) + const [fieldName, setFieldName] = useState("") + const [fieldResetCooldown, setFieldResetCooldown] = useState(0) + const [fieldChangedRows, setFieldChangedRows] = useState>(new Set()) + const [fieldModalOpen, setFieldModalOpen] = useState(false) + + const [newUser, setNewUser] = useState({ + name: "", + isAdmin: false, + field: "", + department: "", + team: "", + role: "", + }) + const [message, setMessage] = useState("") + const [loadingUsers, setLoadingUsers] = useState(false) + const [modalOpen, setModalOpen] = useState(false) + const [teamModalOpen, setTeamModalOpen] = useState(false) + const [selectedCategory, setSelectedCategory] = useState("") + const [selectedCategoryName, setSelectedCategoryName] = useState("") + const [isRefreshing, setIsRefreshing] = useState(false) + const [changedRows, setChangedRows] = useState>(new Set()) + const [teamChangedRows, setTeamChangedRows] = useState>(new Set()) + const [viewMode, setViewMode] = useState<"list" | "pie">("list") + const [debugMode, setDebugMode] = useState(false) + const [useSimpleChart, setUseSimpleChart] = useState(false) + const router = useRouter() + + const [reportModalOpen, setReportModalOpen] = useState(false) + const [selectedUserForReport, setSelectedUserForReport] = useState(null) + + // Global real-time updates + const { isConnected: globalConnected, refetch: refetchGlobal } = useRealTimeUpdates((data) => { + if (data.stats) { + setGlobalStats(data.stats) + } + + if (data.users) { + const newChangedRows = new Set() + data.users.forEach((newUser: UserData) => { + const existingUser = globalUsers.find((u) => u.national_id === newUser.national_id) + if ( + existingUser && + (existingUser.in_shelter !== newUser.in_shelter || existingUser.last_updated !== newUser.last_updated) + ) { + newChangedRows.add(newUser.national_id) + } + }) + + setGlobalUsers(data.users) + setChangedRows(newChangedRows) + + if (newChangedRows.size > 0) { + setTimeout(() => setChangedRows(new Set()), 3000) + } + } + + if (data.lastReset?.lastReset) { + setGlobalLastReset(data.lastReset.lastReset) + if (data.lastReset.timestamp) { + const resetTime = new Date(data.lastReset.timestamp).getTime() + const now = new Date().getTime() + const cooldownMs = 2 * 60 * 1000 // 2 minutes + const remaining = Math.max(0, cooldownMs - (now - resetTime)) + setGlobalResetCooldown(Math.ceil(remaining / 1000)) + } + } + }) + + // Team real-time updates + const { isConnected: teamConnected, refetch: refetchTeam } = useTeamRealTimeUpdates( + user?.national_id || "", + (data) => { + if (data.stats) { + setTeamStats(data.stats) + } + + if (data.users) { + const newChangedRows = new Set() + data.users.forEach((newUser: UserData) => { + const existingUser = teamUsers.find((u) => u.national_id === newUser.national_id) + if ( + existingUser && + (existingUser.in_shelter !== newUser.in_shelter || existingUser.last_updated !== newUser.last_updated) + ) { + newChangedRows.add(newUser.national_id) + } + }) + + setTeamUsers(data.users) + setTeamChangedRows(newChangedRows) + + if (newChangedRows.size > 0) { + setTimeout(() => setTeamChangedRows(new Set()), 3000) + } + } + + if (data.team) { + setTeamName(data.team) + } + }, + ) + + // Department real-time updates + const { isConnected: departmentConnected, refetch: refetchDepartment } = useDepartmentRealTimeUpdates( + user?.national_id || "", + (data) => { + if (data.stats) { + setDepartmentStats(data.stats) + } + + if (data.users) { + const newChangedRows = new Set() + data.users.forEach((newUser: UserData) => { + const existingUser = departmentUsers.find((u) => u.national_id === newUser.national_id) + if ( + existingUser && + (existingUser.in_shelter !== newUser.in_shelter || existingUser.last_updated !== newUser.last_updated) + ) { + newChangedRows.add(newUser.national_id) + } + }) + + setDepartmentUsers(data.users) + setDepartmentChangedRows(newChangedRows) + + if (newChangedRows.size > 0) { + setTimeout(() => setDepartmentChangedRows(new Set()), 3000) + } + } + + if (data.department) { + setDepartmentName(data.department) + } + }, + ) + + // Field real-time updates + const { isConnected: fieldConnected, refetch: refetchField } = useFieldRealTimeUpdates( + user?.national_id || "", + (data) => { + if (data.stats) { + setFieldStats(data.stats) + } + + if (data.users) { + const newChangedRows = new Set() + data.users.forEach((newUser: UserData) => { + const existingUser = fieldUsers.find((u) => u.national_id === newUser.national_id) + if ( + existingUser && + (existingUser.in_shelter !== newUser.in_shelter || existingUser.last_updated !== newUser.last_updated) + ) { + newChangedRows.add(newUser.national_id) + } + }) + + setFieldUsers(data.users) + setFieldChangedRows(newChangedRows) + + if (newChangedRows.size > 0) { + setTimeout(() => setFieldChangedRows(new Set()), 3000) + } + } + + if (data.field) { + setFieldName(data.field) + } + }, + ) + + useEffect(() => { + const userData = localStorage.getItem("user") + if (!userData) { + router.push("/login") + return + } + + const parsedUser = JSON.parse(userData) + if (!["global_admin", "field_admin", "department_admin", "team_admin"].includes(parsedUser.role)) { + router.push("/dashboard") + return + } + + + setUser(parsedUser) + }, [router]) + + useEffect(() => { + if (globalResetCooldown > 0) { + const timer = setTimeout(() => setGlobalResetCooldown(globalResetCooldown - 1), 1000) + return () => clearTimeout(timer) + } + }, [globalResetCooldown]) + + useEffect(() => { + if (teamResetCooldown > 0) { + const timer = setTimeout(() => setTeamResetCooldown(teamResetCooldown - 1), 1000) + return () => clearTimeout(timer) + } + }, [teamResetCooldown]) + + useEffect(() => { + if (departmentResetCooldown > 0) { + const timer = setTimeout(() => setDepartmentResetCooldown(departmentResetCooldown - 1), 1000) + return () => clearTimeout(timer) + } + }, [departmentResetCooldown]) + + useEffect(() => { + if (fieldResetCooldown > 0) { + const timer = setTimeout(() => setFieldResetCooldown(fieldResetCooldown - 1), 1000) + return () => clearTimeout(timer) + } + }, [fieldResetCooldown]) + + const handleGlobalResetAll = async () => { + if (globalResetCooldown > 0) return + + try { + const response = await fetch("/api/admin/reset-all", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ adminId: user?.national_id }), + }) + + const data = await response.json() + + if (response.ok) { + setMessage(data.message || "כל הסטטוסים אופסו בהצלחה") + setGlobalResetCooldown(120) // 2 minutes + setGlobalLastReset(`${user?.name} - ${new Date().toLocaleString("he-IL")}`) + refetchGlobal() + refetchTeam() // Also refresh team data + refetchDepartment() + refetchField() + } else { + // Handle cooldown error specifically + if (response.status === 429 && data.remainingSeconds) { + setGlobalResetCooldown(data.remainingSeconds) + setMessage(`יש להמתין ${data.remainingSeconds} שניות לפני איפוס נוסף`) + } else { + setMessage(data.error || "שגיאה באיפוס הסטטוסים") + } + } + } catch (err) { + setMessage("שגיאה באיפוס הסטטוסים") + } + } + + const handleTeamReset = async () => { + if (teamResetCooldown > 0) return + + try { + const response = await fetch("/api/admin/team-reset", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ adminId: user?.national_id }), + }) + + const data = await response.json() + + if (response.ok) { + setMessage(data.message || `כל הסטטוסים של צוות ${data.team} אופסו בהצלחה`) + setTeamResetCooldown(60) // 1 minute + refetchTeam() + refetchGlobal() // Also refresh global data + refetchDepartment() + refetchField() + } else { + if (response.status === 429 && data.remainingSeconds) { + setTeamResetCooldown(data.remainingSeconds) + setMessage(`יש להמתין ${data.remainingSeconds} שניות לפני איפוס צוות נוסף`) + } else { + setMessage(data.error || "שגיאה באיפוס הצוות") + } + } + } catch (err) { + setMessage("שגיאה באיפוס הצוות") + } + } + + const handleDepartmentReset = async () => { + if (departmentResetCooldown > 0) return + + try { + const response = await fetch("/api/admin/department-reset", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ adminId: user?.national_id }), + }) + + const data = await response.json() + + if (response.ok) { + setMessage(data.message || `כל הסטטוסים של מסגרת ${data.department} אופסו בהצלחה`) + setDepartmentResetCooldown(90) // 1.5 minutes + refetchDepartment() + refetchGlobal() // Also refresh global data + refetchTeam() + refetchField() + } else { + if (response.status === 429 && data.remainingSeconds) { + setDepartmentResetCooldown(data.remainingSeconds) + setMessage(`יש להמתין ${data.remainingSeconds} שניות לפני איפוס מסגרת נוסף`) + } else { + setMessage(data.error || "שגיאה באיפוס המסגרת") + } + } + } catch (err) { + setMessage("שגיאה באיפוס המסגרת") + } + } + + const handleFieldReset = async () => { + if (fieldResetCooldown > 0) return + + try { + const response = await fetch("/api/admin/field-reset", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ adminId: user?.national_id }), + }) + + const data = await response.json() + + if (response.ok) { + setMessage(data.message || `כל הסטטוסים של תחום ${data.field} אופסו בהצלחה`) + setFieldResetCooldown(120) // 2 minutes + refetchField() + refetchGlobal() // Also refresh global data + refetchTeam() + refetchDepartment() + } else { + if (response.status === 429 && data.remainingSeconds) { + setFieldResetCooldown(data.remainingSeconds) + setMessage(`יש להמתין ${data.remainingSeconds} שניות לפני איפוס תחום נוסף`) + } else { + setMessage(data.error || "שגיאה באיפוס התחום") + } + } + } catch (err) { + setMessage("שגיאה באיפוס התחום") + } + } + + const handleAddUser = async (e: React.FormEvent) => { + e.preventDefault() + + if (!newUser.field || !newUser.department || !newUser.team || !newUser.role) { + setMessage("יש לבחור תפקיד, תחום, מסגרת וצוות") + return + } + + try { + const response = await fetch("/api/admin/add-user", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + ...newUser, + adminId: user?.national_id, + }), + }) + + const data = await response.json() + + if (response.ok) { + setMessage(`${data.message}. הסיסמה הזמנית: password123`) + setNewUser({ name: "", isAdmin: false, field: "", department: "", team: "", role: "" }) + refetchGlobal() + refetchTeam() + refetchDepartment() + refetchField() + } else { + setMessage(data.error || "שגיאה בהוספת משתמש") + } + } catch (err) { + setMessage("שגיאה בחיבור לשרת") + } + } + + const handleDeleteUser = async (nationalId: string) => { + try { + const response = await fetch(`/api/admin/users/${nationalId}`, { + method: "DELETE", + }) + + if (response.ok) { + setMessage("משתמש נמחק בהצלחה") + refetchGlobal() + refetchTeam() + refetchDepartment() + refetchField() + } else { + const data = await response.json() + setMessage(data.error || "שגיאה במחיקת משתמש") + } + } catch (err) { + setMessage("שגיאה בחיבור לשרת") + } + } + + const handleResetPassword = async (nationalId: string, userName: string) => { + try { + const response = await fetch("/api/admin/reset-password", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + adminId: user?.national_id, + targetUserId: nationalId, + }), + }) + + if (response.ok) { + setMessage(`סיסמה אופסה בהצלחה עבור ${userName}. הסיסמה החדשה: password123`) + refetchGlobal() + refetchTeam() + refetchDepartment() + refetchField() + } else { + const data = await response.json() + setMessage(data.error || "שגיאה באיפוס סיסמה") + } + } catch (err) { + setMessage("שגיאה בחיבור לשרת") + } + } + + const handleToggleUserLock = async (nationalId: string, currentLockStatus: boolean, userName: string) => { + try { + const response = await fetch("/api/admin/toggle-user-lock", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + adminId: user?.national_id, + targetUserId: nationalId, + lockStatus: !currentLockStatus, + }), + }) + + const data = await response.json() + + if (response.ok) { + setMessage(data.message) + + // Immediately update the local state to reflect the change + const updateUserLockStatus = (users: UserData[]) => + users.map((u) => (u.national_id === nationalId ? { ...u, lock_status: !currentLockStatus } : u)) + + setGlobalUsers((prev) => updateUserLockStatus(prev)) + setTeamUsers((prev) => updateUserLockStatus(prev)) + setDepartmentUsers((prev) => updateUserLockStatus(prev)) + setFieldUsers((prev) => updateUserLockStatus(prev)) + + // Also refresh from server to ensure consistency + setTimeout(() => { + refetchGlobal() + refetchTeam() + refetchDepartment() + refetchField() + }, 100) + } else { + setMessage(data.error || "שגיאה בשינוי סטטוס נעילה") + } + } catch (err) { + setMessage("שגיאה בחיבור לשרת") + } + } + + const getStatusText = (status?: string) => { + switch (status) { + case "yes": + return { text: "במקלט/חדר מוגן", color: "text-green-600" } + case "no": + return { text: "לא במקלט", color: "text-orange-600" } + case "no_alarm": + return { text: "אין אזעקה", color: "text-blue-600" } + case "safe_after_exit": + return { text: "אני בטוח.ה (סוף אירוע)", color: "text-emerald-600" } + default: + return { text: "דיווח חסר", color: "text-gray-500" } + } + } + + const handleGlobalCategoryClick = (category: string, categoryName: string) => { + setSelectedCategory(category) + setSelectedCategoryName(categoryName) + setModalOpen(true) + } + + const handleTeamCategoryClick = (category: string, categoryName: string) => { + setSelectedCategory(category) + setSelectedCategoryName(categoryName) + setTeamModalOpen(true) + } + + const handleDepartmentCategoryClick = (category: string, categoryName: string) => { + setSelectedCategory(category) + setSelectedCategoryName(categoryName) + setDepartmentModalOpen(true) + } + + const handleFieldCategoryClick = (category: string, categoryName: string) => { + setSelectedCategory(category) + setSelectedCategoryName(categoryName) + setFieldModalOpen(true) + } + + const handleManualRefresh = () => { + setIsRefreshing(true) + refetchGlobal() + refetchTeam() + refetchDepartment() + refetchField() + setTimeout(() => setIsRefreshing(false), 500) + } + + const handleReportOnBehalf = async (userId: string, status: string) => { + try { + const response = await fetch("/api/admin/report-on-behalf", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + adminId: user?.national_id, + targetUserId: userId, + status, + }), + }) + + const data = await response.json() + if (response.ok) { + setMessage(data.message) + refetchGlobal() + refetchTeam() + refetchDepartment() + refetchField() + } else { + setMessage(data.error || "שגיאה בדיווח") + } + } catch (err) { + setMessage("שגיאה בחיבור לשרת") + } + } + + const formatCooldownTime = (seconds: number) => { + if (seconds <= 0) return "" + + const minutes = Math.floor(seconds / 60) + const remainingSeconds = seconds % 60 + + if (minutes > 0) { + return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}` + } + return `${remainingSeconds} שניות` + } + + const renderStatsSection = ( + stats: Stats | null, + onCategoryClick: (category: string, categoryName: string) => void, + isTeam = false, + customName?: string, + ) => { + const displayName = customName || (isTeam ? `צוות ${teamName}` : "כלליות") + + return ( + + + +
+ + {isTeam + ? `צוות ${teamName}` + : customName + ? `מסגרת ${customName}` + : "כללי"}{" "} + {isRefreshing && } + {(isTeam + ? teamConnected + : departmentConnected + ? departmentConnected + : fieldConnected + ? fieldConnected + : globalConnected) &&
} +
+
+ + +
+
+
+ + {stats ? ( + <> + {viewMode === "list" ? ( +
+
onCategoryClick("no_report", "לא דיווחו")} + > + לא דיווחו: + {stats.no_report} +
+
onCategoryClick("in_shelter", "במקלט/חדר מוגן")} + > + במקלט: + {stats.in_shelter} +
+
onCategoryClick("not_in_shelter", "לא במקלט - אין מקלט בקרבת מקום")} + > + לא במקלט: + {stats.not_in_shelter} +
+
onCategoryClick("no_alarm", "אין אזעקה באזור")} + > + אין אזעקה: + {stats.no_alarm} +
+
onCategoryClick("safe_after_exit", "אני בטוח.ה (סוף אירוע)")} + > + אני בטוח.ה (סוף אירוע) + {stats.safe_after_exit} +
+
+ ) : useSimpleChart ? ( + + ) : ( + + )} + + ) : ( +
טוען סטטיסטיקות...
+ )} +
+ {( + isTeam + ? teamConnected + : departmentConnected + ? departmentConnected + : fieldConnected + ? fieldConnected + : globalConnected + ) ? ( + + ) : ( + "מנסה להתחבר לעדכונים..." + )} +
+
+
+ ) + } + + const renderUsersTable = (users: UserData[], changedRows: Set, isReadOnly = false) => { + return ( +
+ + + + שם + דיווח + {!isReadOnly && פעולות} + תחום + מסגרת + צוות + + + + {users.map((userData) => { + const status = getStatusText(userData.in_shelter) + const isChanged = changedRows.has(userData.national_id) + const isLocked = userData.lock_status || false + return ( + + + {userData.name} + {isChanged && 🔄} + + + {status.text} + + {!isReadOnly && ( + +
+ + + +
+
+ )} + + + {userData.field || "לא הוגדר"} + + + + + {userData.department || "לא הוגדר"} + + + + + {userData.team || "לא הוגדר"} + + + + + +
+ ) + })} +
+
+ {users.length === 0 &&
אין משתמשים
} +
+ ) + } + + if (!user) return null + + return ( +
+
+ + +
+ ניהול +
+
+ {globalConnected || teamConnected || departmentConnected || fieldConnected ? ( + <> + + מקוון + + ) : ( + <> + + מתחבר... + + )} +
+ + {user?.role !== "user" && ( + + )} + +
+
+
+
+ + {message && ( + + {message} + + )} + + {debugMode && ( + + + Debug Info: +
+
Team: {teamName}
+
Global Reset Cooldown: {globalResetCooldown} seconds
+
Team Reset Cooldown: {teamResetCooldown} seconds
+
Department Reset Cooldown: {departmentResetCooldown} seconds
+
Field Reset Cooldown: {fieldResetCooldown} seconds
+
+
+
+ )} + + + + + + צוות + + + + מסגרת + + + + תחום + + + + כללי + + + + +
+ + + + + איפוס סטטוסי הצוות + + + + +
+ הערה: איפוס יאפס רק את המשתמשים מהצוות שלך ({teamName}) שאינם נעולים +
+
+
+ + {renderStatsSection(teamStats, handleTeamCategoryClick, true)} +
+ + + + + + ניהול משתמשי צוות {teamName} + {teamChangedRows.size > 0 && ( + + {teamChangedRows.size} עדכונים חדשים + + )} + + + + {loadingUsers ? ( +
טוען משתמשים...
+ ) : ( + renderUsersTable(teamUsers, teamChangedRows) + )} +
+
+
+ + +
+ + + + + {user?.role === "team_admin" ? "צפייה במסגרת" : "איפוס סטטוסי המסגרת"} + + + + {user?.role === "team_admin" ? ( +
+

צפייה בלבד

+

כמנהל צוות, אתה יכול לראות את המסגרת שלך אך לא לאפס אותה

+
+ ) : ( + <> + +
+ הערה: איפוס יאפס את כל המשתמשים מהמסגרת שלך ({departmentName}) שאינם נעולים +
+ + )} +
+
+ + {renderStatsSection(departmentStats, handleDepartmentCategoryClick, false, departmentName)} +
+ + + + + + {user?.role === "team_admin" ? "צפייה במשתמשי מסגרת" : "ניהול משתמשי מסגרת"} {departmentName} + {departmentChangedRows.size > 0 && ( + + {departmentChangedRows.size} עדכונים חדשים + + )} + + + + {loadingUsers ? ( +
טוען משתמשים...
+ ) : ( + renderUsersTable(departmentUsers, departmentChangedRows, user?.role === "team_admin") + )} +
+
+
+ + +
+ + + + + איפוס סטטוסי התחום + + + + {(user?.role === "department_admin" || user?.role === "team_admin") ? ( +
+

צפייה בלבד

+

כ{ROLE_NAMES[user.role]}, אתה יכול לראות את התחום שלך אך לא לאפס אותו

+
+ ) : ( + <> + +
+ הערה: איפוס יאפס את כל המשתמשים מהתחום שלך ({fieldName}) שאינם נעולים +
+ + )} +
+
+ + {renderStatsSection(fieldStats, handleFieldCategoryClick, false, fieldName)} +
+ + + + + + {(user?.role === "team_admin" || user?.role === "department_admin") ? "צפייה במשתמשי תחום" : "ניהול משתמשי תחום"} {fieldName} + {fieldChangedRows.size > 0 && ( + + {fieldChangedRows.size} עדכונים חדשים + + )} + + + + {loadingUsers ? ( +
טוען משתמשים...
+ ) : ( + renderUsersTable(fieldUsers, fieldChangedRows, (user?.role === "team_admin" || user?.role === "department_admin")) + )} +
+
+
+ + +
+ + + + + איפוס סטטוסים כללי + + + + + {globalLastReset &&

איפוס אחרון: {globalLastReset}

} +
+ הערה: איפוס יאפס את כל המשתמשים במערכת (כולל מנהלים) שאינם נעולים +
+
+
+ + {renderStatsSection(globalStats, handleGlobalCategoryClick, false)} +
+ + + + + + הוספת משתמש חדש + + + +
+
+
+ + setNewUser({ ...newUser, name: e.target.value })} + placeholder="שם" + required + /> +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ הערה: המשתמש יקבל את הסיסמה הזמנית "password123" ויידרש לשנותה בכניסה הראשונה +
+ +
+
+
+ + + + + + ניהול כל המשתמשים + {changedRows.size > 0 && ( + + {changedRows.size} עדכונים חדשים + + )} + + + + {loadingUsers ? ( +
טוען משתמשים...
+ ) : ( + renderUsersTable(globalUsers, changedRows) + )} +
+
+
+
+ + setModalOpen(false)} + category={selectedCategory} + categoryName={selectedCategoryName} + /> + + setTeamModalOpen(false)} + category={selectedCategory} + categoryName={selectedCategoryName} + adminId={user?.national_id || ""} + teamName={teamName} + /> + + setDepartmentModalOpen(false)} + category={selectedCategory} + categoryName={selectedCategoryName} + adminId={user?.national_id || ""} + departmentName={departmentName} + /> + + setFieldModalOpen(false)} + category={selectedCategory} + categoryName={selectedCategoryName} + adminId={user?.national_id || ""} + fieldName={fieldName} + /> + + setReportModalOpen(false)} + user={selectedUserForReport} + onReport={handleReportOnBehalf} + /> + + {/* Hostname Footer */} + + +
+ סביבה: {process.env.NEXT_PUBLIC_HOSTNAME || process.env.HOSTNAME || "לא זוהה"} +
+ 2025 COPYRIGHT TR-WEB +
+
+
+
+
+ ) +} diff --git a/app/api/admin/add-user/route.ts b/app/api/admin/add-user/route.ts new file mode 100644 index 0000000..45d9b85 --- /dev/null +++ b/app/api/admin/add-user/route.ts @@ -0,0 +1,58 @@ +import { type NextRequest, NextResponse } from "next/server" +import { safeQuery } from "@/lib/database" +import { hashPassword } from "@/lib/auth" +import { type UserRole, DEPARTMENTS, TEAMS, FIELDS } from "@/types/user" + +export async function POST(request: NextRequest) { + try { + const { name, isAdmin, field, department, team, role } = await request.json() + + // Input validation + if (!name || !field || !department || !team) { + return NextResponse.json({ error: "נתונים חסרים" }, { status: 400 }) + } + + // Validate department, team, and field + if (!FIELDS.includes(field as any)) { + return NextResponse.json({ error: "תחום לא תקין" }, { status: 400 }) + } + + if (!DEPARTMENTS.includes(department as any)) { + return NextResponse.json({ error: "מסגרת לא תקינה" }, { status: 400 }) + } + + if (!TEAMS.includes(team as any)) { + return NextResponse.json({ error: "צוות לא תקין" }, { status: 400 }) + } + + const validRoles: UserRole[] = ["user", "team_admin", "department_admin", "field_admin", "global_admin"] + + // Set role based on isAdmin flag or explicit role + const userRole: UserRole = (role as UserRole) || (isAdmin ? "global_admin" : "user") + + if (!validRoles.includes(userRole)) { + return NextResponse.json({ error: "תפקיד לא תקין" }, { status: 400 }) + } + + // Generate unique Login ID + const { generateUniqueIsraeliID } = await import("@/lib/auth") + const nationalId = await generateUniqueIsraeliID() + + // Hash default password "password123" + const hashedPassword = await hashPassword("password123") + + await safeQuery( + "INSERT INTO users (national_id, password, name, is_admin, role, must_change_password, field, department, team) VALUES (?, ?, ?, ?, ?, TRUE, ?, ?, ?)", + [nationalId, hashedPassword, name, isAdmin, userRole, field, department, team], + ) + + return NextResponse.json({ + success: true, + nationalId: nationalId, + message: `משתמש ${name} נוסף בהצלחה עם מזהה: ${nationalId}`, + }) + } catch (error) { + console.error("Add user error:", error) + return NextResponse.json({ error: "שגיאה בהוספת משתמש" }, { status: 500 }) + } +} diff --git a/app/api/admin/change-role/route.ts b/app/api/admin/change-role/route.ts new file mode 100644 index 0000000..ecc2fcf --- /dev/null +++ b/app/api/admin/change-role/route.ts @@ -0,0 +1,76 @@ +import { type NextRequest, NextResponse } from "next/server" +import { safeQuery } from "@/lib/database" +import { type UserRole, ROLE_HIERARCHY, ROLE_NAMES } from "@/types/user" + +export async function POST(request: NextRequest) { + try { + const { adminId, targetUserId, newRole } = await request.json() + + if (!adminId || !targetUserId || !newRole) { + return NextResponse.json({ error: "נתונים חסרים" }, { status: 400 }) + } + + // Get admin data + const adminData = (await safeQuery("SELECT role, field, department, team FROM users WHERE national_id = ?", [ + adminId, + ])) as any[] + + if (adminData.length === 0) { + return NextResponse.json({ error: "מנהל לא נמצא" }, { status: 404 }) + } + + const admin = adminData[0] + const adminLevel = ROLE_HIERARCHY[admin.role as UserRole] || 0 + + // Get target user data + const targetData = (await safeQuery("SELECT role, field, department, team, name FROM users WHERE national_id = ?", [ + targetUserId, + ])) as any[] + + if (targetData.length === 0) { + return NextResponse.json({ error: "משתמש לא נמצא" }, { status: 404 }) + } + + const target = targetData[0] + const newRoleLevel = ROLE_HIERARCHY[newRole as UserRole] || 0 + + // Check if admin has permission to change this role + if (adminLevel <= newRoleLevel) { + return NextResponse.json({ error: "אין הרשאה לתת תפקיד זה" }, { status: 403 }) + } + + // Check if admin can manage this user based on hierarchy + let canManage = false + + if (admin.role === "global_admin") { + canManage = true + } else if (admin.role === "field_admin" && admin.field === target.field) { + canManage = true + } else if (admin.role === "department_admin" && admin.department === target.department) { + canManage = true + } else if (admin.role === "team_admin" && admin.team === target.team) { + canManage = true + } + + if (!canManage) { + return NextResponse.json({ error: "אין הרשאה לנהל משתמש זה" }, { status: 403 }) + } + + // Update user role + await safeQuery("UPDATE users SET role = ? WHERE national_id = ?", [newRole, targetUserId]) + + // Log the action + await safeQuery( + 'INSERT INTO admin_actions (admin_id, action_type, target_user_id, target_role) VALUES (?, "role_change", ?, ?)', + [adminId, targetUserId, newRole], + ) + + return NextResponse.json({ + success: true, + message: `תפקיד ${target.name} שונה ל${ROLE_NAMES[newRole as UserRole]}`, + }) + } catch (error) { + console.error("Change role error:", error) + return NextResponse.json({ error: "שגיאה בשינוי תפקיד" }, { status: 500 }) + } +} diff --git a/app/api/admin/db-health/route.ts b/app/api/admin/db-health/route.ts new file mode 100644 index 0000000..4678b7b --- /dev/null +++ b/app/api/admin/db-health/route.ts @@ -0,0 +1,54 @@ +import { NextResponse } from "next/server" +import { healthCheck, getPoolStats } from "@/lib/database" + +export async function GET() { + try { + const health = await healthCheck() + const poolStats = getPoolStats() + + return NextResponse.json({ + ...health, + poolStats, + recommendations: generateRecommendations(poolStats), + }) + } catch (error) { + console.error("Database health check error:", error) + return NextResponse.json( + { + status: "error", + error: error instanceof Error ? error.message : "Unknown error", + timestamp: new Date().toISOString(), + }, + { status: 500 }, + ) + } +} + +function generateRecommendations(stats: any) { + const recommendations = [] + + if (!stats) { + recommendations.push("Unable to get pool statistics") + return recommendations + } + + const utilizationRate = (stats.totalConnections - stats.freeConnections) / stats.connectionLimit + + if (utilizationRate > 0.8) { + recommendations.push("High connection utilization - consider increasing connection limit") + } + + if (stats.acquiringConnections > 5) { + recommendations.push("Many connections waiting - possible connection leak or high load") + } + + if (stats.freeConnections === 0) { + recommendations.push("No free connections available - increase pool size or check for connection leaks") + } + + if (recommendations.length === 0) { + recommendations.push("Database pool is healthy") + } + + return recommendations +} diff --git a/app/api/admin/department-reset/route.ts b/app/api/admin/department-reset/route.ts new file mode 100644 index 0000000..a842d44 --- /dev/null +++ b/app/api/admin/department-reset/route.ts @@ -0,0 +1,79 @@ +import { type NextRequest, NextResponse } from "next/server" +import { safeQuery } from "@/lib/database" + +export async function POST(request: NextRequest) { + try { + const { adminId } = await request.json() + + if (!adminId) { + return NextResponse.json({ error: "מזהה מנהל חסר" }, { status: 400 }) + } + + // Get admin's field and department + const adminData = (await safeQuery( + "SELECT field, department FROM users WHERE national_id = ? AND role IS NOT NULL AND role != 'user'", + [adminId], + )) as any[] + + if (adminData.length === 0) { + return NextResponse.json({ error: "מנהל לא נמצא" }, { status: 404 }) + } + + const { field: adminField, department: adminDepartment } = adminData[0] + + if (!adminField || !adminDepartment) { + return NextResponse.json({ error: "למנהל לא הוגדרו תחום ומסגרת" }, { status: 400 }) + } + + // Check cooldown for department resets + const lastReset = (await safeQuery( + 'SELECT timestamp FROM admin_actions WHERE action_type = "reset_department" AND admin_id = ? ORDER BY timestamp DESC LIMIT 1', + [adminId], + )) as any[] + + if (lastReset.length > 0) { + const lastResetTime = new Date(lastReset[0].timestamp).getTime() + const now = new Date().getTime() + const cooldownMs = 90 * 10 // 1.5 minutes for department resets + + if (now - lastResetTime < cooldownMs) { + return NextResponse.json({ error: "יש להמתין 90 שניות בין איפוסי מסגרת" }, { status: 429 }) + } + } + + // Reset department users' statuses with field and department context, but exclude locked users + await safeQuery( + "UPDATE users SET in_shelter = NULL, last_updated = NULL WHERE field = ? AND department = ? AND lock_status = FALSE", + [adminField, adminDepartment], + ) + + // Get count of locked users that were skipped + const lockedUsers = (await safeQuery( + "SELECT COUNT(*) as count FROM users WHERE field = ? AND department = ? AND lock_status = TRUE", + [adminField, adminDepartment], + )) as any[] + const lockedCount = lockedUsers[0]?.count || 0 + + // Log the action + await safeQuery( + 'INSERT INTO admin_actions (admin_id, action_type, target_user_id) VALUES (?, "reset_department", NULL)', + [adminId], + ) + + let message = `כל הסטטוסים של מסגרת ${adminDepartment} בתחום ${adminField} אופסו בהצלחה` + if (lockedCount > 0) { + message += ` (${lockedCount} משתמשים נעולים לא אופסו)` + } + + return NextResponse.json({ + success: true, + field: adminField, + department: adminDepartment, + message, + lockedCount, + }) + } catch (error) { + console.error("Department reset error:", error) + return NextResponse.json({ error: "שגיאה באיפוס המסגרת" }, { status: 500 }) + } +} diff --git a/app/api/admin/department-stats/route.ts b/app/api/admin/department-stats/route.ts new file mode 100644 index 0000000..d02945e --- /dev/null +++ b/app/api/admin/department-stats/route.ts @@ -0,0 +1,52 @@ +import { NextResponse } from "next/server" +import { executeQuery } from "@/lib/database" + +export async function POST(request: Request) { + try { + const { adminId } = await request.json() + + if (!adminId) { + return NextResponse.json({ error: "מזהה מנהל חסר" }, { status: 400 }) + } + + // Get admin's field and department + const adminData = (await executeQuery( + "SELECT field, department FROM users WHERE national_id = ? AND role IS NOT NULL AND role != 'user'", + [adminId], + )) as any[] + + if (adminData.length === 0) { + return NextResponse.json({ error: "מנהל לא נמצא" }, { status: 404 }) + } + + const { field: adminField, department: adminDepartment } = adminData[0] + + if (!adminField || !adminDepartment) { + return NextResponse.json({ error: "למנהל לא הוגדרו תחום ומסגרת" }, { status: 400 }) + } + + // Get department stats with field and department context + const results = (await executeQuery( + ` + SELECT + SUM(CASE WHEN in_shelter IS NULL THEN 1 ELSE 0 END) as no_report, + SUM(CASE WHEN in_shelter = 'yes' THEN 1 ELSE 0 END) as in_shelter, + SUM(CASE WHEN in_shelter = 'no' THEN 1 ELSE 0 END) as not_in_shelter, + SUM(CASE WHEN in_shelter = 'no_alarm' THEN 1 ELSE 0 END) as no_alarm, + SUM(CASE WHEN in_shelter = 'safe_after_exit' THEN 1 ELSE 0 END) as safe_after_exit + FROM users + WHERE field = ? AND department = ? + `, + [adminField, adminDepartment], + )) as any[] + + return NextResponse.json({ + ...results[0], + field: adminField, + department: adminDepartment, + }) + } catch (error) { + console.error("Department stats error:", error) + return NextResponse.json({ error: "שגיאה בטעינת סטטיסטיקות המסגרת" }, { status: 500 }) + } +} diff --git a/app/api/admin/department-users-by-category/route.ts b/app/api/admin/department-users-by-category/route.ts new file mode 100644 index 0000000..e99e70c --- /dev/null +++ b/app/api/admin/department-users-by-category/route.ts @@ -0,0 +1,62 @@ +import { type NextRequest, NextResponse } from "next/server" +import { executeQuery } from "@/lib/database" + +export async function POST(request: NextRequest) { + try { + const { adminId, category } = await request.json() + + if (!adminId || !category) { + return NextResponse.json({ error: "נתונים חסרים" }, { status: 400 }) + } + + // Get admin's field and department + const adminData = (await executeQuery( + "SELECT field, department FROM users WHERE national_id = ? AND role IS NOT NULL AND role != 'user'", + [adminId], + )) as any[] + + if (adminData.length === 0) { + return NextResponse.json({ error: "מנהל לא נמצא" }, { status: 404 }) + } + + const { field: adminField, department: adminDepartment } = adminData[0] + + if (!adminField || !adminDepartment) { + return NextResponse.json({ error: "למנהל לא הוגדרו תחום ומסגרת" }, { status: 400 }) + } + + let query = "" + const params: any[] = [adminField, adminDepartment] + + switch (category) { + case "no_report": + query = + "SELECT national_id, name, team FROM users WHERE field = ? AND department = ? AND in_shelter IS NULL ORDER BY team, name" + break + case "in_shelter": + query = + "SELECT national_id, name, team FROM users WHERE field = ? AND department = ? AND in_shelter = 'yes' ORDER BY team, name" + break + case "not_in_shelter": + query = + "SELECT national_id, name, team FROM users WHERE field = ? AND department = ? AND in_shelter = 'no' ORDER BY team, name" + break + case "no_alarm": + query = + "SELECT national_id, name, team FROM users WHERE field = ? AND department = ? AND in_shelter = 'no_alarm' ORDER BY team, name" + break + case "safe_after_exit": + query = + "SELECT national_id, name , team FROM users WHERE field = ? AND department = ? AND in_shelter = 'safe_after_exit' ORDER BY name" + break + default: + return NextResponse.json({ error: "קטגוריה לא תקינה" }, { status: 400 }) + } + + const users = (await executeQuery(query, params)) as any[] + return NextResponse.json(users) + } catch (error) { + console.error("Get department users by category error:", error) + return NextResponse.json({ error: "שגיאה בטעינת משתמשי המסגרת" }, { status: 500 }) + } +} diff --git a/app/api/admin/department-users/route.ts b/app/api/admin/department-users/route.ts new file mode 100644 index 0000000..8bea9ac --- /dev/null +++ b/app/api/admin/department-users/route.ts @@ -0,0 +1,58 @@ +import { NextResponse } from "next/server" +import { executeQuery } from "@/lib/database" + +export async function POST(request: Request) { + try { + const { adminId } = await request.json() + + if (!adminId) { + return NextResponse.json({ error: "מזהה מנהל חסר" }, { status: 400 }) + } + + // Get admin's field and department + const adminData = (await executeQuery( + "SELECT field, department FROM users WHERE national_id = ? AND role IS NOT NULL AND role != 'user'", + [adminId], + )) as any[] + + if (adminData.length === 0) { + return NextResponse.json({ error: "מנהל לא נמצא" }, { status: 404 }) + } + + const { field: adminField, department: adminDepartment } = adminData[0] + + if (!adminField || !adminDepartment) { + return NextResponse.json({ error: "למנהל לא הוגדרו תחום ומסגרת" }, { status: 400 }) + } + + // Get department users with field and department context + const users = (await executeQuery( + ` + SELECT + national_id, + name, + in_shelter, + last_updated, + is_admin, + must_change_password, + field, + department, + team, + lock_status + FROM users + WHERE field = ? AND department = ? + ORDER BY team, name + `, + [adminField, adminDepartment], + )) as any[] + + return NextResponse.json({ + users, + field: adminField, + department: adminDepartment, + }) + } catch (error) { + console.error("Department users error:", error) + return NextResponse.json({ error: "שגיאה בטעינת משתמשי המסגרת" }, { status: 500 }) + } +} diff --git a/app/api/admin/events/route.ts b/app/api/admin/events/route.ts new file mode 100644 index 0000000..1ecb8bc --- /dev/null +++ b/app/api/admin/events/route.ts @@ -0,0 +1,61 @@ +export const dynamic = 'force-dynamic'; + +import type { NextRequest } from "next/server" + +// Store active connections +const connections = new Set() + +export async function GET(request: NextRequest) { + const stream = new ReadableStream({ + start(controller) { + connections.add(controller) + + // Send initial connection message + controller.enqueue(`data: ${JSON.stringify({ type: "connected", timestamp: new Date().toISOString() })}\n\n`) + + // Keep connection alive with periodic pings + const pingInterval = setInterval(() => { + try { + controller.enqueue(`data: ${JSON.stringify({ type: "ping", timestamp: new Date().toISOString() })}\n\n`) + } catch (err) { + clearInterval(pingInterval) + connections.delete(controller) + } + }, 30000) + + // Clean up on close + request.signal.addEventListener("abort", () => { + clearInterval(pingInterval) + connections.delete(controller) + try { + controller.close() + } catch (err) { + // Connection already closed + } + }) + }, + }) + + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "Cache-Control", + }, + }) +} + +// Function to broadcast updates to all connected clients +export function broadcastUpdate(data: any) { + const message = `data: ${JSON.stringify({ type: "update", data, timestamp: new Date().toISOString() })}\n\n` + + connections.forEach((controller) => { + try { + controller.enqueue(message) + } catch (err) { + connections.delete(controller) + } + }) +} diff --git a/app/api/admin/field-reset/route.ts b/app/api/admin/field-reset/route.ts new file mode 100644 index 0000000..0bafb6a --- /dev/null +++ b/app/api/admin/field-reset/route.ts @@ -0,0 +1,70 @@ +import { type NextRequest, NextResponse } from "next/server" +import { safeQuery } from "@/lib/database" + +export async function POST(request: NextRequest) { + try { + const { adminId } = await request.json() + + if (!adminId) { + return NextResponse.json({ error: "מזהה מנהל חסר" }, { status: 400 }) + } + + // Get admin's field + const adminData = (await safeQuery("SELECT field FROM users WHERE national_id = ? AND role IS NOT NULL AND role != 'user'", [ + adminId, + ])) as any[] + + if (adminData.length === 0) { + return NextResponse.json({ error: "מנהל לא נמצא" }, { status: 404 }) + } + + const adminField = adminData[0].field + + if (!adminField) { + return NextResponse.json({ error: "למנהל לא הוגדר תחום" }, { status: 400 }) + } + + // Check cooldown for field resets + const lastReset = (await safeQuery( + 'SELECT timestamp FROM admin_actions WHERE action_type = "reset_field" AND admin_id = ? ORDER BY timestamp DESC LIMIT 1', + [adminId], + )) as any[] + + if (lastReset.length > 0) { + const lastResetTime = new Date(lastReset[0].timestamp).getTime() + const now = new Date().getTime() + const cooldownMs = 2 * 60 * 10 // 2 minutes for field resets + + if (now - lastResetTime < cooldownMs) { + return NextResponse.json({ error: "יש להמתין 2 דקות בין איפוסי תחום" }, { status: 429 }) + } + } + + // Reset field users' statuses, but exclude locked users + await safeQuery("UPDATE users SET in_shelter = NULL, last_updated = NULL WHERE field = ? AND lock_status = FALSE", [ + adminField, + ]) + + // Get count of locked users that were skipped + const lockedUsers = (await safeQuery("SELECT COUNT(*) as count FROM users WHERE field = ? AND lock_status = TRUE", [ + adminField, + ])) as any[] + const lockedCount = lockedUsers[0]?.count || 0 + + // Log the action + await safeQuery( + 'INSERT INTO admin_actions (admin_id, action_type, target_user_id) VALUES (?, "reset_field", NULL)', + [adminId], + ) + + let message = `כל הסטטוסים של תחום ${adminField} אופסו בהצלחה` + if (lockedCount > 0) { + message += ` (${lockedCount} משתמשים נעולים לא אופסו)` + } + + return NextResponse.json({ success: true, field: adminField, message, lockedCount }) + } catch (error) { + console.error("Field reset error:", error) + return NextResponse.json({ error: "שגיאה באיפוס התחום" }, { status: 500 }) + } +} diff --git a/app/api/admin/field-stats/route.ts b/app/api/admin/field-stats/route.ts new file mode 100644 index 0000000..e92f145 --- /dev/null +++ b/app/api/admin/field-stats/route.ts @@ -0,0 +1,50 @@ +import { NextResponse } from "next/server" +import { executeQuery } from "@/lib/database" + +export async function POST(request: Request) { + try { + const { adminId } = await request.json() + + if (!adminId) { + return NextResponse.json({ error: "מזהה מנהל חסר" }, { status: 400 }) + } + + // Get admin's field + const adminData = (await executeQuery("SELECT field FROM users WHERE national_id = ? AND role IS NOT NULL AND role != 'user'", [ + adminId, + ])) as any[] + + if (adminData.length === 0) { + return NextResponse.json({ error: "מנהל לא נמצא" }, { status: 404 }) + } + + const adminField = adminData[0].field + + if (!adminField) { + return NextResponse.json({ error: "למנהל לא הוגדר תחום" }, { status: 400 }) + } + + // Get field stats + const results = (await executeQuery( + ` + SELECT + SUM(CASE WHEN in_shelter IS NULL THEN 1 ELSE 0 END) as no_report, + SUM(CASE WHEN in_shelter = 'yes' THEN 1 ELSE 0 END) as in_shelter, + SUM(CASE WHEN in_shelter = 'no' THEN 1 ELSE 0 END) as not_in_shelter, + SUM(CASE WHEN in_shelter = 'no_alarm' THEN 1 ELSE 0 END) as no_alarm, + SUM(CASE WHEN in_shelter = 'safe_after_exit' THEN 1 ELSE 0 END) as safe_after_exit + FROM users + WHERE field = ? + `, + [adminField], + )) as any[] + + return NextResponse.json({ + ...results[0], + field: adminField, + }) + } catch (error) { + console.error("Field stats error:", error) + return NextResponse.json({ error: "שגיאה בטעינת סטטיסטיקות התחום" }, { status: 500 }) + } +} diff --git a/app/api/admin/field-users-by-category/route.ts b/app/api/admin/field-users-by-category/route.ts new file mode 100644 index 0000000..24d5ffa --- /dev/null +++ b/app/api/admin/field-users-by-category/route.ts @@ -0,0 +1,60 @@ +import { type NextRequest, NextResponse } from "next/server" +import { executeQuery } from "@/lib/database" + +export async function POST(request: NextRequest) { + try { + const { adminId, category } = await request.json() + + if (!adminId || !category) { + return NextResponse.json({ error: "נתונים חסרים" }, { status: 400 }) + } + + // Get admin's field + const adminData = (await executeQuery("SELECT field FROM users WHERE national_id = ? AND role IS NOT NULL AND role != 'user'", [ + adminId, + ])) as any[] + + if (adminData.length === 0) { + return NextResponse.json({ error: "מנהל לא נמצא" }, { status: 404 }) + } + + const adminField = adminData[0].field + + if (!adminField) { + return NextResponse.json({ error: "למנהל לא הוגדר תחום" }, { status: 400 }) + } + + let query = "" + const params: any[] = [adminField] + + switch (category) { + case "no_report": + query = + "SELECT national_id, name, department, team FROM users WHERE field = ? AND in_shelter IS NULL ORDER BY department, team, name" + break + case "in_shelter": + query = + "SELECT national_id, name, department, team FROM users WHERE field = ? AND in_shelter = 'yes' ORDER BY department, team, name" + break + case "not_in_shelter": + query = + "SELECT national_id, name, department, team FROM users WHERE field = ? AND in_shelter = 'no' ORDER BY department, team, name" + break + case "no_alarm": + query = + "SELECT national_id, name, department, team FROM users WHERE field = ? AND in_shelter = 'no_alarm' ORDER BY department, team, name" + break + case "safe_after_exit": + query = "SELECT national_id, name, department, team FROM users WHERE in_shelter = 'safe_after_exit' ORDER BY name" + break + default: + return NextResponse.json({ error: "קטגוריה לא תקינה" }, { status: 400 }) + } + + const users = (await executeQuery(query, params)) as any[] + return NextResponse.json(users) + } catch (error) { + console.error("Get field users by category error:", error) + return NextResponse.json({ error: "שגיאה בטעינת משתמשי התחום" }, { status: 500 }) + } +} diff --git a/app/api/admin/field-users/route.ts b/app/api/admin/field-users/route.ts new file mode 100644 index 0000000..58c8ee7 --- /dev/null +++ b/app/api/admin/field-users/route.ts @@ -0,0 +1,56 @@ +import { NextResponse } from "next/server" +import { executeQuery } from "@/lib/database" + +export async function POST(request: Request) { + try { + const { adminId } = await request.json() + + if (!adminId) { + return NextResponse.json({ error: "מזהה מנהל חסר" }, { status: 400 }) + } + + // Get admin's field + const adminData = (await executeQuery("SELECT field FROM users WHERE national_id = ? AND role IS NOT NULL AND role != 'user'", [ + adminId, + ])) as any[] + + if (adminData.length === 0) { + return NextResponse.json({ error: "מנהל לא נמצא" }, { status: 404 }) + } + + const adminField = adminData[0].field + + if (!adminField) { + return NextResponse.json({ error: "למנהל לא הוגדר תחום" }, { status: 400 }) + } + + // Get field users + const users = (await executeQuery( + ` + SELECT + national_id, + name, + in_shelter, + last_updated, + is_admin, + must_change_password, + field, + department, + team, + lock_status + FROM users + WHERE field = ? + ORDER BY department, team, name + `, + [adminField], + )) as any[] + + return NextResponse.json({ + users, + field: adminField, + }) + } catch (error) { + console.error("Field users error:", error) + return NextResponse.json({ error: "שגיאה בטעינת משתמשי התחום" }, { status: 500 }) + } +} diff --git a/app/api/admin/last-reset/route.ts b/app/api/admin/last-reset/route.ts new file mode 100644 index 0000000..46b6ccc --- /dev/null +++ b/app/api/admin/last-reset/route.ts @@ -0,0 +1,28 @@ +import { NextResponse } from "next/server" +import { executeQuery } from "@/lib/database" + +export async function GET() { + try { + const results = (await executeQuery(` + SELECT aa.timestamp, u.name + FROM admin_actions aa + JOIN users u ON aa.admin_id = u.national_id + WHERE aa.action_type = 'reset_all' + ORDER BY aa.timestamp DESC + LIMIT 1 + `)) as any[] + + if (results.length > 0) { + const result = results[0] + return NextResponse.json({ + lastReset: `${result.name} - ${new Date(result.timestamp).toLocaleString("he-IL")}`, + timestamp: result.timestamp, + }) + } + + return NextResponse.json({ lastReset: null }) + } catch (error) { + console.error("Last reset error:", error) + return NextResponse.json({ error: "שגיאה בטעינת נתונים" }, { status: 500 }) + } +} diff --git a/app/api/admin/manageable-users/route.ts b/app/api/admin/manageable-users/route.ts new file mode 100644 index 0000000..a1099bd --- /dev/null +++ b/app/api/admin/manageable-users/route.ts @@ -0,0 +1,122 @@ +import { type NextRequest, NextResponse } from "next/server" +import { safeQuery } from "@/lib/database" + +export async function POST(request: NextRequest) { + try { + const { adminId } = await request.json() + + if (!adminId) { + return NextResponse.json({ error: "מזהה מנהל חסר" }, { status: 400 }) + } + + // Get admin data + const adminData = (await safeQuery("SELECT role, field, department, team FROM users WHERE national_id = ?", [ + adminId, + ])) as any[] + + if (adminData.length === 0) { + return NextResponse.json({ error: "מנהל לא נמצא" }, { status: 404 }) + } + + const admin = adminData[0] + let query = "" + let params: any[] = [] + + // Build query based on admin role + if (admin.role === "global_admin") { + query = ` + SELECT + national_id, + name, + role, + field, + department, + team, + in_shelter, + last_updated, + lock_status, + is_admin, + must_change_password + FROM users + ORDER BY field, department, team, name + ` + } else if (admin.role === "field_admin") { + query = ` + SELECT + national_id, + name, + role, + field, + department, + team, + in_shelter, + last_updated, + lock_status, + is_admin, + must_change_password + FROM users + WHERE field = ? + ORDER BY department, team, name + ` + params = [admin.field] + } else if (admin.role === "department_admin") { + query = ` + SELECT + national_id, + name, + role, + field, + department, + team, + in_shelter, + last_updated, + lock_status, + is_admin, + must_change_password + FROM users + WHERE department = ? + ORDER BY team, name + ` + params = [admin.department] + } else if (admin.role === "team_admin") { + // Team admins can only manage their own team members + query = ` + SELECT + national_id, + name, + role, + field, + department, + team, + in_shelter, + last_updated, + lock_status, + is_admin, + must_change_password + FROM users + WHERE team = ? AND role = 'user' + ORDER BY name + ` + params = [admin.team] + } else { + return NextResponse.json({ error: "אין הרשאות ניהול" }, { status: 403 }) + } + + const users = (await safeQuery(query, params)) as any[] + + console.log("Manageable users query result:", users) // Debug log + + return NextResponse.json({ + users, + adminRole: admin.role, + scope: { + field: admin.field, + department: admin.department, + team: admin.team, + }, + }) + } catch (error) { + console.error("Get manageable users error:", error) + return NextResponse.json({ error: "שגיאה בטעינת משתמשים" }, { status: 500 }) + } +} diff --git a/app/api/admin/report-on-behalf/route.ts b/app/api/admin/report-on-behalf/route.ts new file mode 100644 index 0000000..1c049f9 --- /dev/null +++ b/app/api/admin/report-on-behalf/route.ts @@ -0,0 +1,86 @@ +import { type NextRequest, NextResponse } from "next/server" +import { safeQuery } from "@/lib/database" + +export async function POST(request: NextRequest) { + try { + const { adminId, targetUserId, status } = await request.json() + + if (!adminId || !targetUserId || !status) { + return NextResponse.json({ error: "נתונים חסרים" }, { status: 400 }) + } + + if (!["yes", "no", "no_alarm", "safe_after_exit"].includes(status)) { + return NextResponse.json({ error: "סטטוס לא תקין" }, { status: 400 }) + } + + // Get admin data + const adminData = (await safeQuery("SELECT role, field, department, team, name FROM users WHERE national_id = ?", [ + adminId, + ])) as any[] + + if (adminData.length === 0) { + return NextResponse.json({ error: "מנהל לא נמצא" }, { status: 404 }) + } + + const admin = adminData[0] + + // Get target user data + const targetData = (await safeQuery("SELECT field, department, team, name FROM users WHERE national_id = ?", [ + targetUserId, + ])) as any[] + + if (targetData.length === 0) { + return NextResponse.json({ error: "משתמש לא נמצא" }, { status: 404 }) + } + + const target = targetData[0] + + // Check if admin can manage this user based on hierarchy + let canManage = false + + if (admin.role === "global_admin") { + canManage = true + } else if (admin.role === "field_admin" && admin.field === target.field) { + canManage = true + } else if (admin.role === "department_admin" && admin.department === target.department) { + canManage = true + } else if (admin.role === "team_admin" && admin.team === target.team) { + canManage = true + } + + if (!canManage) { + return NextResponse.json({ error: "אין הרשאה לדווח עבור משתמש זה" }, { status: 403 }) + } + + // Update user status + await safeQuery("UPDATE users SET in_shelter = ?, last_updated = NOW() WHERE national_id = ?", [ + status, + targetUserId, + ]) + + // Log the action + await safeQuery( + 'INSERT INTO admin_actions (admin_id, action_type, target_user_id) VALUES (?, "report_on_behalf", ?)', + [adminId, targetUserId], + ) + + const statusText = + status === "yes" + ? "במקלט/חדר מוגן" + : status === "no" + ? "לא במקלט" + : status === "no_alarm" + ? "אין אזעקה" + : status === "safe_after_exit" + ? "בטוח אחרי יציאה מהמקלט" + : "לא ידוע" + + return NextResponse.json({ + success: true, + message: `דווח עבור ${target.name}: ${statusText}`, + }) + } catch (error) { + console.error("Report on behalf error:", error) + return NextResponse.json({ error: "שגיאה בדיווח" }, { status: 500 }) + } +} diff --git a/app/api/admin/reset-all/route.ts b/app/api/admin/reset-all/route.ts new file mode 100644 index 0000000..4800be0 --- /dev/null +++ b/app/api/admin/reset-all/route.ts @@ -0,0 +1,72 @@ +import { type NextRequest, NextResponse } from "next/server" +import { safeQuery } from "@/lib/database" + +export async function POST(request: NextRequest) { + try { + const { adminId } = await request.json() + + // Check cooldown + const lastReset = (await safeQuery( + 'SELECT timestamp FROM admin_actions WHERE action_type = "reset_all" ORDER BY timestamp DESC LIMIT 1', + )) as any[] + + if (lastReset.length > 0) { + const lastResetTime = new Date(lastReset[0].timestamp).getTime() + const now = new Date().getTime() + const cooldownMs = 2 * 60 * 1000 // 2 minutes in milliseconds + + const timeSinceReset = now - lastResetTime + + console.log("Reset cooldown check:", { + lastResetTime: new Date(lastResetTime).toISOString(), + now: new Date(now).toISOString(), + timeSinceReset: timeSinceReset, + cooldownMs: cooldownMs, + remainingMs: cooldownMs - timeSinceReset, + }) + + if (timeSinceReset < cooldownMs) { + const remainingSeconds = Math.ceil((cooldownMs - timeSinceReset) / 1000) + return NextResponse.json( + { + error: `יש להמתין ${remainingSeconds} שניות בין איפוסים`, + remainingSeconds, + cooldownMs, + }, + { status: 429 }, + ) + } + } + + // Reset ALL users' statuses including admins, but exclude locked users + const result = await safeQuery("UPDATE users SET in_shelter = NULL, last_updated = NULL WHERE lock_status = FALSE") + + // Get count of locked users that were skipped + const lockedUsers = (await safeQuery("SELECT COUNT(*) as count FROM users WHERE lock_status = TRUE")) as any[] + const lockedCount = lockedUsers[0]?.count || 0 + + // Log the action + await safeQuery('INSERT INTO admin_actions (admin_id, action_type) VALUES (?, "reset_all")', [adminId]) + + let message = "כל הסטטוסים אופסו בהצלחה" + if (lockedCount > 0) { + message += ` (${lockedCount} משתמשים נעולים לא אופסו)` + } + + console.log("Reset completed:", { + affectedRows: (result as any).affectedRows, + lockedCount, + adminId, + }) + + return NextResponse.json({ + success: true, + message, + lockedCount, + affectedRows: (result as any).affectedRows, + }) + } catch (error) { + console.error("Reset error:", error) + return NextResponse.json({ error: "שגיאה באיפוס" }, { status: 500 }) + } +} diff --git a/app/api/admin/reset-password/route.ts b/app/api/admin/reset-password/route.ts new file mode 100644 index 0000000..ca8a37e --- /dev/null +++ b/app/api/admin/reset-password/route.ts @@ -0,0 +1,56 @@ +import { type NextRequest, NextResponse } from "next/server" +import { safeQuery } from "@/lib/database" +import { hashPassword } from "@/lib/auth" + +export async function POST(request: NextRequest) { + try { + const { adminId, targetUserId } = await request.json() + + // Input validation + if (!adminId || !targetUserId) { + return NextResponse.json({ error: "נתונים חסרים" }, { status: 400 }) + } + + // Verify admin permissions + const admins = await safeQuery( + "SELECT role FROM users WHERE national_id = ?", + [adminId] + ) as { role: string | null }[] + + if ( + admins.length === 0 || + !admins[0].role || + admins[0].role === "user" + ) { + return NextResponse.json({ error: "אין הרשאות מנהל" }, { status: 403 }) + } + + // Check if target user exists + const targetUsers = (await safeQuery("SELECT national_id FROM users WHERE national_id = ?", [ + targetUserId, + ])) as any[] + + if (targetUsers.length === 0) { + return NextResponse.json({ error: "משתמש לא נמצא" }, { status: 404 }) + } + + // Reset password to "password123" + const hashedPassword = await hashPassword("password123") + + await safeQuery( + "UPDATE users SET password = ?, must_change_password = TRUE, password_changed_at = NULL WHERE national_id = ?", + [hashedPassword, targetUserId], + ) + + // Log the action + await safeQuery( + 'INSERT INTO admin_actions (admin_id, action_type, target_user_id) VALUES (?, "reset_password", ?)', + [adminId, targetUserId], + ) + + return NextResponse.json({ success: true }) + } catch (error) { + console.error("Reset password error:", error) + return NextResponse.json({ error: "שגיאה באיפוס סיסמה" }, { status: 500 }) + } +} diff --git a/app/api/admin/stats/route.ts b/app/api/admin/stats/route.ts new file mode 100644 index 0000000..24a2167 --- /dev/null +++ b/app/api/admin/stats/route.ts @@ -0,0 +1,21 @@ +import { NextResponse } from "next/server" +import { executeQuery } from "@/lib/database" + +export async function GET() { + try { + const results = (await executeQuery(` + SELECT + SUM(CASE WHEN in_shelter IS NULL THEN 1 ELSE 0 END) as no_report, + SUM(CASE WHEN in_shelter = 'yes' THEN 1 ELSE 0 END) as in_shelter, + SUM(CASE WHEN in_shelter = 'no' THEN 1 ELSE 0 END) as not_in_shelter, + SUM(CASE WHEN in_shelter = 'no_alarm' THEN 1 ELSE 0 END) as no_alarm, + SUM(CASE WHEN in_shelter = 'safe_after_exit' THEN 1 ELSE 0 END) as safe_after_exit + FROM users + `)) as any[] + + return NextResponse.json(results[0]) + } catch (error) { + console.error("Stats error:", error) + return NextResponse.json({ error: "שגיאה בטעינת סטטיסטיקות" }, { status: 500 }) + } +} diff --git a/app/api/admin/team-reset/route.ts b/app/api/admin/team-reset/route.ts new file mode 100644 index 0000000..b898a0e --- /dev/null +++ b/app/api/admin/team-reset/route.ts @@ -0,0 +1,66 @@ +import { type NextRequest, NextResponse } from "next/server" +import { safeQuery } from "@/lib/database" +import { revalidatePath } from 'next/cache' // Crucial for Next.js App Router caching + +export async function POST(request: NextRequest) { + try { + const { adminId } = await request.json() + + if (!adminId) { + return NextResponse.json({ error: "מזהה מנהל חסר" }, { status: 400 }) + } + + // Get admin's field, department, and team + const adminData = (await safeQuery( + "SELECT field, department, team FROM users WHERE national_id = ? AND role IS NOT NULL AND role != 'user'", + [adminId], + )) as any[] + + if (adminData.length === 0) { + return NextResponse.json({ error: "מנהל לא נמצא" }, { status: 404 }) + } + + const { field: adminField, department: adminDepartment, team: adminTeam } = adminData[0] + + if (!adminField || !adminDepartment || !adminTeam) { + return NextResponse.json({ error: "למנהל לא הוגדרו תחום, מסגרת וצוות" }, { status: 400 }) + } + + await safeQuery( + "UPDATE users SET in_shelter = NULL, last_updated = NULL WHERE field = ? AND department = ? AND team = ? AND lock_status = FALSE", + [adminField, adminDepartment, adminTeam], + ) + + // Get count of locked users that were skipped + const lockedUsers = (await safeQuery( + "SELECT COUNT(*) as count FROM users WHERE field = ? AND department = ? AND team = ? AND lock_status = TRUE", + [adminField, adminDepartment, adminTeam], + )) as any[] + const lockedCount = lockedUsers[0]?.count || 0 + + // Log the action + await safeQuery( + 'INSERT INTO admin_actions (admin_id, action_type, target_user_id) VALUES (?, "reset_team", NULL)', + [adminId], + ) + + revalidatePath('/admin') + + let message = `כל הסטטוסים של צוות ${adminTeam} במסגרת ${adminDepartment} בתחום ${adminField} אופסו בהצלחה` + if (lockedCount > 0) { + message += ` (${lockedCount} משתמשים נעולים לא אופסו)` + } + + return NextResponse.json({ + success: true, + field: adminField, + department: adminDepartment, + team: adminTeam, + message, + lockedCount, + }, { status: 200 }) + } catch (error) { + console.error("Team reset error:", error) + return NextResponse.json({ error: "שגיאה באיפוס הצוות" }, { status: 500 }) + } +} \ No newline at end of file diff --git a/app/api/admin/team-stats/route.ts b/app/api/admin/team-stats/route.ts new file mode 100644 index 0000000..706f6b5 --- /dev/null +++ b/app/api/admin/team-stats/route.ts @@ -0,0 +1,53 @@ +import { NextResponse } from "next/server" +import { executeQuery } from "@/lib/database" + +export async function POST(request: Request) { + try { + const { adminId } = await request.json() + + if (!adminId) { + return NextResponse.json({ error: "מזהה מנהל חסר" }, { status: 400 }) + } + + // Get admin's field, department, and team + const adminData = (await executeQuery( + "SELECT field, department, team FROM users WHERE national_id = ? AND role IS NOT NULL AND role != 'user'", + [adminId], + )) as any[] + + if (adminData.length === 0) { + return NextResponse.json({ error: "מנהל לא נמצא" }, { status: 404 }) + } + + const { field: adminField, department: adminDepartment, team: adminTeam } = adminData[0] + + if (!adminField || !adminDepartment || !adminTeam) { + return NextResponse.json({ error: "למנהל לא הוגדרו תחום, מסגרת וצוות" }, { status: 400 }) + } + + // Get team stats with full context (field + department + team) + const results = (await executeQuery( + ` + SELECT + SUM(CASE WHEN in_shelter IS NULL THEN 1 ELSE 0 END) as no_report, + SUM(CASE WHEN in_shelter = 'yes' THEN 1 ELSE 0 END) as in_shelter, + SUM(CASE WHEN in_shelter = 'no' THEN 1 ELSE 0 END) as not_in_shelter, + SUM(CASE WHEN in_shelter = 'no_alarm' THEN 1 ELSE 0 END) as no_alarm, + SUM(CASE WHEN in_shelter = 'safe_after_exit' THEN 1 ELSE 0 END) as safe_after_exit + FROM users + WHERE field = ? AND department = ? AND team = ? + `, + [adminField, adminDepartment, adminTeam], + )) as any[] + + return NextResponse.json({ + ...results[0], + field: adminField, + department: adminDepartment, + team: adminTeam, + }) + } catch (error) { + console.error("Team stats error:", error) + return NextResponse.json({ error: "שגיאה בטעינת סטטיסטיקות הצוות" }, { status: 500 }) + } +} diff --git a/app/api/admin/team-users-by-category/route.ts b/app/api/admin/team-users-by-category/route.ts new file mode 100644 index 0000000..23321b8 --- /dev/null +++ b/app/api/admin/team-users-by-category/route.ts @@ -0,0 +1,62 @@ +import { type NextRequest, NextResponse } from "next/server" +import { executeQuery } from "@/lib/database" + +export async function POST(request: NextRequest) { + try { + const { adminId, category } = await request.json() + + if (!adminId || !category) { + return NextResponse.json({ error: "נתונים חסרים" }, { status: 400 }) + } + + // Get admin's field, department, and team + const adminData = (await executeQuery( + "SELECT field, department, team FROM users WHERE national_id = ? AND role IS NOT NULL AND role != 'user'", + [adminId], + )) as any[] + + if (adminData.length === 0) { + return NextResponse.json({ error: "מנהל לא נמצא" }, { status: 404 }) + } + + const { field: adminField, department: adminDepartment, team: adminTeam } = adminData[0] + + if (!adminField || !adminDepartment || !adminTeam) { + return NextResponse.json({ error: "למנהל לא הוגדרו תחום, מסגרת וצוות" }, { status: 400 }) + } + + let query = "" + const params: any[] = [adminField, adminDepartment, adminTeam] + + switch (category) { + case "no_report": + query = + "SELECT national_id, name FROM users WHERE field = ? AND department = ? AND team = ? AND in_shelter IS NULL ORDER BY name" + break + case "in_shelter": + query = + "SELECT national_id, name FROM users WHERE field = ? AND department = ? AND team = ? AND in_shelter = 'yes' ORDER BY name" + break + case "not_in_shelter": + query = + "SELECT national_id, name FROM users WHERE field = ? AND department = ? AND team = ? AND in_shelter = 'no' ORDER BY name" + break + case "no_alarm": + query = + "SELECT national_id, name FROM users WHERE field = ? AND department = ? AND team = ? AND in_shelter = 'no_alarm' ORDER BY name" + break + case "safe_after_exit": + query = + "SELECT national_id, name FROM users WHERE field = ? AND department = ? AND team = ? AND in_shelter = 'safe_after_exit' ORDER BY name" + break + default: + return NextResponse.json({ error: "קטגוריה לא תקינה" }, { status: 400 }) + } + + const users = (await executeQuery(query, params)) as any[] + return NextResponse.json(users) + } catch (error) { + console.error("Get team users by category error:", error) + return NextResponse.json({ error: "שגיאה בטעינת משתמשי הצוות" }, { status: 500 }) + } +} diff --git a/app/api/admin/team-users/route.ts b/app/api/admin/team-users/route.ts new file mode 100644 index 0000000..d8c2ea9 --- /dev/null +++ b/app/api/admin/team-users/route.ts @@ -0,0 +1,58 @@ +import { NextResponse } from "next/server" +import { executeQuery } from "@/lib/database" + +export async function POST(request: Request) { + try { + const { adminId } = await request.json() + + if (!adminId) { + return NextResponse.json({ error: "מזהה מנהל חסר" }, { status: 400 }) + } + + // Get admin's field, department, and team + const adminData = (await executeQuery("SELECT field, department, team FROM users WHERE national_id = ? AND role IS NOT NULL AND role != 'user'", [ + adminId, + ])) as any[] + + if (adminData.length === 0) { + return NextResponse.json({ error: "מנהל לא נמצא" }, { status: 404 }) + } + + const { field: adminField, department: adminDepartment, team: adminTeam } = adminData[0] + + if (!adminField || !adminDepartment || !adminTeam) { + return NextResponse.json({ error: "למנהל לא הוגדרו תחום, מסגרת וצוות" }, { status: 400 }) + } + + // Get team users with full context (field + department + team) + const users = (await executeQuery( + ` + SELECT + national_id, + name, + in_shelter, + last_updated, + is_admin, + must_change_password, + field, + department, + team, + lock_status + FROM users + WHERE field = ? AND department = ? AND team = ? + ORDER BY name + `, + [adminField, adminDepartment, adminTeam], + )) as any[] + + return NextResponse.json({ + users, + field: adminField, + department: adminDepartment, + team: adminTeam, + }) + } catch (error) { + console.error("Team users error:", error) + return NextResponse.json({ error: "שגיאה בטעינת משתמשי הצוות" }, { status: 500 }) + } +} diff --git a/app/api/admin/toggle-user-lock/route.ts b/app/api/admin/toggle-user-lock/route.ts new file mode 100644 index 0000000..4ecbd45 --- /dev/null +++ b/app/api/admin/toggle-user-lock/route.ts @@ -0,0 +1,74 @@ +import { type NextRequest, NextResponse } from "next/server" +import { safeQuery } from "@/lib/database" + +export async function POST(request: NextRequest) { + try { + const { adminId, targetUserId, lockStatus } = await request.json() + + if (!adminId || !targetUserId || typeof lockStatus !== "boolean") { + return NextResponse.json({ error: "נתונים חסרים" }, { status: 400 }) + } + + // Get admin data + const adminData = (await safeQuery("SELECT role, field, department, team FROM users WHERE national_id = ?", [ + adminId, + ])) as any[] + + if (adminData.length === 0) { + return NextResponse.json({ error: "מנהל לא נמצא" }, { status: 404 }) + } + + const admin = adminData[0] + + // Get target user data + const targetData = (await safeQuery( + "SELECT field, department, team, name, lock_status FROM users WHERE national_id = ?", + [targetUserId], + )) as any[] + + if (targetData.length === 0) { + return NextResponse.json({ error: "משתמש לא נמצא" }, { status: 404 }) + } + + const target = targetData[0] + + // Check if admin can manage this user based on hierarchy + let canManage = false + + if (admin.role === "global_admin") { + canManage = true + } else if (admin.role === "field_admin" && admin.field === target.field) { + canManage = true + } else if (admin.role === "department_admin" && admin.department === target.department) { + canManage = true + } else if (admin.role === "team_admin" && admin.team === target.team) { + canManage = true + } + + if (!canManage) { + return NextResponse.json({ error: "אין הרשאה לנעול/לבטל נעילה של משתמש זה" }, { status: 403 }) + } + + // Update user lock status + await safeQuery("UPDATE users SET lock_status = ? WHERE national_id = ?", [lockStatus, targetUserId]) + + // Log the action + const actionType = lockStatus ? "lock_user" : "unlock_user" + await safeQuery("INSERT INTO admin_actions (admin_id, action_type, target_user_id) VALUES (?, ?, ?)", [ + adminId, + actionType, + targetUserId, + ]) + + const statusText = lockStatus ? "נעול" : "לא נעול" + + return NextResponse.json({ + success: true, + message: `סטטוס נעילה של ${target.name} שונה ל: ${statusText}`, + lockStatus, + }) + } catch (error) { + console.error("Toggle user lock error:", error) + return NextResponse.json({ error: "שגיאה בשינוי סטטוס נעילה" }, { status: 500 }) + } +} diff --git a/app/api/admin/users-by-category/route.ts b/app/api/admin/users-by-category/route.ts new file mode 100644 index 0000000..8a25bfb --- /dev/null +++ b/app/api/admin/users-by-category/route.ts @@ -0,0 +1,45 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { executeQuery } from "@/lib/database"; + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const category = searchParams.get("category"); + + let query = ""; + // No params needed for these queries, as there are no WHERE conditions + // that use parameters other than the in_shelter status itself. + let params: any[] = []; + + switch (category) { + case "no_report": + // Added 'department', 'team', 'field' to SELECT clause + query = "SELECT national_id, name, department, team, field FROM users WHERE in_shelter IS NULL ORDER BY name"; + break; + case "in_shelter": + // Added 'department', 'team', 'field' to SELECT clause + query = "SELECT national_id, name, department, team, field FROM users WHERE in_shelter = 'yes' ORDER BY name"; + break; + case "not_in_shelter": + // Added 'department', 'team', 'field' to SELECT clause + query = "SELECT national_id, name, department, team, field FROM users WHERE in_shelter = 'no' ORDER BY name"; + break; + case "no_alarm": + // Added 'department', 'team', 'field' to SELECT clause + query = "SELECT national_id, name, department, team, field FROM users WHERE in_shelter = 'no_alarm' ORDER BY name"; + break; + case "safe_after_exit": + // Added 'department', 'team', 'field' to SELECT clause + query = "SELECT national_id, name, department, team, field FROM users WHERE in_shelter = 'safe_after_exit' ORDER BY name"; + break; + default: + return NextResponse.json({ error: "קטגוריה לא תקינה" }, { status: 400 }); + } + + const users = (await executeQuery(query, params)) as any[]; + return NextResponse.json(users); + } catch (error) { + console.error("Get users by category error:", error); + return NextResponse.json({ error: "שגיאה בטעינת משתמשים" }, { status: 500 }); + } +} \ No newline at end of file diff --git a/app/api/admin/users/[id]/route.ts b/app/api/admin/users/[id]/route.ts new file mode 100644 index 0000000..34e1dfa --- /dev/null +++ b/app/api/admin/users/[id]/route.ts @@ -0,0 +1,27 @@ +import { type NextRequest, NextResponse } from "next/server" +import { executeQuery } from "@/lib/database" + +export async function DELETE(request: NextRequest, { params }: { params: { id: string } }) { + try { + const nationalId = params.id + + // Check if user exists + const users = (await executeQuery("SELECT national_id FROM users WHERE national_id = ?", [nationalId])) as any[] + + if (users.length === 0) { + return NextResponse.json({ error: "משתמש לא נמצא" }, { status: 404 }) + } + + // Delete user + await executeQuery( + "DELETE FROM admin_actions WHERE admin_id = ? OR target_user_id = ?", + [nationalId, nationalId] + ); + await executeQuery("DELETE FROM users WHERE national_id = ?", [nationalId]) + + return NextResponse.json({ success: true }) + } catch (error) { + console.error("Delete user error:", error) + return NextResponse.json({ error: "שגיאה במחיקת משתמש" }, { status: 500 }) + } +} diff --git a/app/api/admin/users/route.ts b/app/api/admin/users/route.ts new file mode 100644 index 0000000..b0dd7d6 --- /dev/null +++ b/app/api/admin/users/route.ts @@ -0,0 +1,27 @@ +import { NextResponse } from "next/server" +import { safeQuery } from "@/lib/database" + +export async function GET() { + try { + const users = (await safeQuery(` + SELECT + national_id, + name, + in_shelter, + last_updated, + is_admin, + must_change_password, + field, + department, + team, + lock_status + FROM users + ORDER BY field, department, team, name + `)) as any[] + + return NextResponse.json(users) + } catch (error) { + console.error("Get users error:", error) + return NextResponse.json({ error: "שגיאה בטעינת משתמשים" }, { status: 500 }) + } +} \ No newline at end of file diff --git a/app/api/auth/change-password/route.ts b/app/api/auth/change-password/route.ts new file mode 100644 index 0000000..3f1a697 --- /dev/null +++ b/app/api/auth/change-password/route.ts @@ -0,0 +1,45 @@ +import { type NextRequest, NextResponse } from "next/server" +import { safeQuery } from "@/lib/database" +import { verifyPassword, hashPassword } from "@/lib/auth" + +export async function POST(request: NextRequest) { + try { + const { nationalId, currentPassword, newPassword } = await request.json() + + // Input validation + if (!nationalId || !currentPassword || !newPassword) { + return NextResponse.json({ error: "נתונים חסרים" }, { status: 400 }) + } + + if (newPassword.length < 6) { + return NextResponse.json({ error: "הסיסמה החדשה חייבת להכיל לפחות 6 תווים" }, { status: 400 }) + } + + // Get current user data + const users = (await safeQuery("SELECT password FROM users WHERE national_id = ?", [nationalId])) as any[] + + if (users.length === 0) { + return NextResponse.json({ error: "משתמש לא נמצא" }, { status: 404 }) + } + + const user = users[0] + const isValidCurrentPassword = await verifyPassword(currentPassword, user.password) + + if (!isValidCurrentPassword) { + return NextResponse.json({ error: "הסיסמה הנוכחית שגויה" }, { status: 401 }) + } + + // Hash new password and update + const hashedNewPassword = await hashPassword(newPassword) + + await safeQuery( + "UPDATE users SET password = ?, must_change_password = FALSE, password_changed_at = NOW() WHERE national_id = ?", + [hashedNewPassword, nationalId], + ) + + return NextResponse.json({ success: true }) + } catch (error) { + console.error("Change password error:", error) + return NextResponse.json({ error: "שגיאה בשינוי סיסמה" }, { status: 500 }) + } +} diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts new file mode 100644 index 0000000..87ac664 --- /dev/null +++ b/app/api/auth/login/route.ts @@ -0,0 +1,53 @@ +import { type NextRequest, NextResponse } from "next/server" +import { safeQuery } from "@/lib/database" +import { validateIsraeliID, verifyPassword } from "@/lib/auth" + +export async function POST(request: NextRequest) { + try { + const { nationalId, password } = await request.json() + + // Input validation + if (!nationalId || !password) { + return NextResponse.json({ error: "נתונים חסרים" }, { status: 400 }) + } + + if (!validateIsraeliID(nationalId)) { + return NextResponse.json({ error: "מספר תעודת זהות לא תקין" }, { status: 400 }) + } + + // Use parameterized query to prevent SQL injection + const users = (await safeQuery( + "SELECT national_id, password, name, is_admin, role, field, department, team, in_shelter, last_updated, must_change_password FROM users WHERE national_id = ?", + [nationalId], + )) as any[] + + if (users.length === 0) { + return NextResponse.json({ error: "משתמש לא נמצא" }, { status: 401 }) + } + + const user = users[0] + const isValidPassword = await verifyPassword(password, user.password) + + if (!isValidPassword) { + return NextResponse.json({ error: "סיסמה שגויה" }, { status: 401 }) + } + + return NextResponse.json({ + user: { + national_id: user.national_id, + name: user.name, + is_admin: user.is_admin, + role: user.role, + field: user.field, + department: user.department, + team: user.team, + in_shelter: user.in_shelter, + last_updated: user.last_updated, + must_change_password: user.must_change_password, + }, + }) + } catch (error) { + console.error("Login error:", error) + return NextResponse.json({ error: "שגיאה בשרת" }, { status: 500 }) + } +} diff --git a/app/api/status/update/route.ts b/app/api/status/update/route.ts new file mode 100644 index 0000000..367d7a3 --- /dev/null +++ b/app/api/status/update/route.ts @@ -0,0 +1,28 @@ +import { type NextRequest, NextResponse } from "next/server" +import { safeQuery } from "@/lib/database" +import { broadcastUpdate } from "@/lib/websocket" + +export async function POST(request: NextRequest) { + try { + const { nationalId, status } = await request.json() + + if (!["yes", "no", "no_alarm", "safe_after_exit"].includes(status)) { + return NextResponse.json({ error: "סטטוס לא תקין" }, { status: 400 }) + } + + await safeQuery("UPDATE users SET in_shelter = ?, last_updated = NOW() WHERE national_id = ?", [status, nationalId]) + + // Broadcast the update to all connected admins + broadcastUpdate({ + type: "status_change", + user_id: nationalId, + status: status, + timestamp: new Date().toISOString(), + }) + + return NextResponse.json({ success: true }) + } catch (error) { + console.error("Status update error:", error) + return NextResponse.json({ error: "שגיאה בעדכון סטטוס" }, { status: 500 }) + } +} diff --git a/app/api/test-db/route.ts b/app/api/test-db/route.ts new file mode 100644 index 0000000..93cfe7f --- /dev/null +++ b/app/api/test-db/route.ts @@ -0,0 +1,45 @@ +import { NextResponse } from "next/server" +import { testConnection, executeQuery, getPoolStats, healthCheck } from "@/lib/database" + +export async function GET() { + try { + // Test basic connection + const connectionTest = await testConnection() + if (!connectionTest) { + return NextResponse.json( + { + error: "Database connection failed", + details: "Could not connect to MySQL database", + }, + { status: 500 }, + ) + } + + // Test query execution with automatic connection management + const result = await executeQuery("SELECT COUNT(*) as user_count FROM users") + + // Get pool statistics + const poolStats = getPoolStats() + + // Get comprehensive health check + const health = await healthCheck() + + return NextResponse.json({ + success: true, + message: "Database connection successful", + userCount: (result as any)[0].user_count, + poolStats, + health, + timestamp: new Date().toISOString(), + }) + } catch (error) { + console.error("Database test error:", error) + return NextResponse.json( + { + error: "Database test failed", + details: error instanceof Error ? error.message : "Unknown error", + }, + { status: 500 }, + ) + } +} diff --git a/app/api/websocket/route.ts b/app/api/websocket/route.ts new file mode 100644 index 0000000..9ca0c40 --- /dev/null +++ b/app/api/websocket/route.ts @@ -0,0 +1,6 @@ +import type { NextRequest } from "next/server" + +export async function GET(request: NextRequest) { + // This will be handled by the custom server + return new Response("WebSocket endpoint", { status: 200 }) +} diff --git a/app/apple-icon.png b/app/apple-icon.png new file mode 100644 index 0000000..d7ea0c6 Binary files /dev/null and b/app/apple-icon.png differ diff --git a/app/change-password/page.tsx b/app/change-password/page.tsx new file mode 100644 index 0000000..526a771 --- /dev/null +++ b/app/change-password/page.tsx @@ -0,0 +1,137 @@ +"use client" + +import type React from "react" + +import { useState, useEffect } from "react" +import { useRouter } from "next/navigation" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Alert, AlertDescription } from "@/components/ui/alert" + +export default function ChangePasswordPage() { + const [currentPassword, setCurrentPassword] = useState("") + const [newPassword, setNewPassword] = useState("") + const [confirmPassword, setConfirmPassword] = useState("") + const [error, setError] = useState("") + const [loading, setLoading] = useState(false) + const [user, setUser] = useState(null) + const router = useRouter() + + useEffect(() => { + const userData = localStorage.getItem("user") + if (!userData) { + router.push("/login") + return + } + setUser(JSON.parse(userData)) + }, [router]) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setLoading(true) + setError("") + + if (newPassword !== confirmPassword) { + setError("הסיסמאות החדשות אינן תואמות") + setLoading(false) + return + } + + if (newPassword.length < 6) { + setError("הסיסמה החדשה חייבת להכיל לפחות 6 תווים") + setLoading(false) + return + } + + try { + const response = await fetch("/api/auth/change-password", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + nationalId: user.national_id, + currentPassword, + newPassword, + }), + }) + + const data = await response.json() + + if (response.ok) { + // Update user data + const updatedUser = { ...user, must_change_password: false } + localStorage.setItem("user", JSON.stringify(updatedUser)) + router.push("/dashboard") + } else { + setError(data.error || "שגיאה בשינוי סיסמה") + } + } catch (err) { + setError("שגיאה בחיבור לשרת") + } finally { + setLoading(false) + } + } + + if (!user) return null + + return ( +
+ + + שינוי סיסמה + + {user.must_change_password ? "הסיסמה אופסה או לא שונתה, ויש לשנותה על מנת להמשיך." : "שינוי סיסמה"} + + + +
+
+ + setCurrentPassword(e.target.value)} + placeholder="הזן סיסמה נוכחית" + required + /> +
+
+ + setNewPassword(e.target.value)} + placeholder="הזן סיסמה חדשה (לפחות 6 תווים)" + required + minLength={6} + /> +
+
+ + setConfirmPassword(e.target.value)} + placeholder="הזן שוב את הסיסמה החדשה" + required + minLength={6} + /> +
+ {error && ( + + {error} + + )} + +
+
+
+
+ ) +} diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx new file mode 100644 index 0000000..abb9e0d --- /dev/null +++ b/app/dashboard/page.tsx @@ -0,0 +1,176 @@ +"use client" + +import { useState, useEffect } from "react" +import { useRouter } from "next/navigation" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Shield, ShieldAlert, ShieldX, Settings, LogOut, Users, ShieldCheck, Home, Sun, Smile } from "lucide-react" +import { type User, ROLE_NAMES } from "@/types/user" + +export default function DashboardPage() { + const [user, setUser] = useState(null) + const [selectedStatus, setSelectedStatus] = useState(null) + const [lastUpdated, setLastUpdated] = useState(null) + const [loading, setLoading] = useState(false) + const router = useRouter() + + useEffect(() => { + const userData = localStorage.getItem("user") + if (!userData) { + router.push("/login") + return + } + + const parsedUser = JSON.parse(userData) + setUser(parsedUser) + setSelectedStatus(parsedUser.in_shelter) + setLastUpdated(parsedUser.last_updated) + }, [router]) + + const handleStatusUpdate = async (status: string) => { + if (!user) return + + setLoading(true) + try { + const response = await fetch("/api/status/update", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + nationalId: user.national_id, + status, + }), + }) + + if (response.ok) { + setSelectedStatus(status) + setLastUpdated(new Date().toLocaleString("he-IL")) + } + } catch (err) { + console.error("Error updating status:", err) + } finally { + setLoading(false) + } + } + + const handleLogout = () => { + localStorage.removeItem("user") + router.push("/login") + } + + const canAccessAdmin = () => { + return !!user?.role && user.role !== "user" + } + + if (!user) return null + + const getStatusText = (status: string) => { + switch (status) { + case "yes": + return "במקלט/חדר מוגן" + case "no": + return "לא במקלט - אין מקלט בקרבת מקום" + case "no_alarm": + return "אין אזעקה באזור שלי" + case "safe_after_exit": + return "אני בטוח (אחרי יציאה מהמקלט)" + default: + return "" + } + } + + return ( +
+
+ + + שלום {user.name} +
+ {ROLE_NAMES[user.role] || user.role} + {user.field && ` - תחום ${user.field}`} + {user.department && ` - מסגרת ${user.department}`} + {user.team && ` - צוות ${user.team}`} +
+
+ + {canAccessAdmin() && ( + + )} +
+
+
+ +
+ + + + + + +
+ + {selectedStatus && lastUpdated && ( + + +

סטטוס נוכחי:

+

{getStatusText(selectedStatus)}

+

עודכן: {lastUpdated}

+
+
+ )} + {/* Hostname Footer */} + + +
+ סביבה: {process.env.NEXT_PUBLIC_HOSTNAME || process.env.HOSTNAME || "לא זוהה"} +
+ 2025 COPYRIGHT TR-WEB +
+
+
+
+
+ ) +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..ac68442 --- /dev/null +++ b/app/globals.css @@ -0,0 +1,94 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +body { + font-family: Arial, Helvetica, sans-serif; +} + +@layer utilities { + .text-balance { + text-wrap: balance; + } +} + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 0 0% 3.9%; + --card: 0 0% 100%; + --card-foreground: 0 0% 3.9%; + --popover: 0 0% 100%; + --popover-foreground: 0 0% 3.9%; + --primary: 0 0% 9%; + --primary-foreground: 0 0% 98%; + --secondary: 0 0% 96.1%; + --secondary-foreground: 0 0% 9%; + --muted: 0 0% 96.1%; + --muted-foreground: 0 0% 45.1%; + --accent: 0 0% 96.1%; + --accent-foreground: 0 0% 9%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 98%; + --border: 0 0% 89.8%; + --input: 0 0% 89.8%; + --ring: 0 0% 3.9%; + --chart-1: 12 76% 61%; + --chart-2: 173 58% 39%; + --chart-3: 197 37% 24%; + --chart-4: 43 74% 66%; + --chart-5: 27 87% 67%; + --radius: 0.5rem; + --sidebar-background: 0 0% 98%; + --sidebar-foreground: 240 5.3% 26.1%; + --sidebar-primary: 240 5.9% 10%; + --sidebar-primary-foreground: 0 0% 98%; + --sidebar-accent: 240 4.8% 95.9%; + --sidebar-accent-foreground: 240 5.9% 10%; + --sidebar-border: 220 13% 91%; + --sidebar-ring: 217.2 91.2% 59.8%; + } + .dark { + --background: 0 0% 3.9%; + --foreground: 0 0% 98%; + --card: 0 0% 3.9%; + --card-foreground: 0 0% 98%; + --popover: 0 0% 3.9%; + --popover-foreground: 0 0% 98%; + --primary: 0 0% 98%; + --primary-foreground: 0 0% 9%; + --secondary: 0 0% 14.9%; + --secondary-foreground: 0 0% 98%; + --muted: 0 0% 14.9%; + --muted-foreground: 0 0% 63.9%; + --accent: 0 0% 14.9%; + --accent-foreground: 0 0% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 0% 98%; + --border: 0 0% 14.9%; + --input: 0 0% 14.9%; + --ring: 0 0% 83.1%; + --chart-1: 220 70% 50%; + --chart-2: 160 60% 45%; + --chart-3: 30 80% 55%; + --chart-4: 280 65% 60%; + --chart-5: 340 75% 55%; + --sidebar-background: 240 5.9% 10%; + --sidebar-foreground: 240 4.8% 95.9%; + --sidebar-primary: 224.3 76.3% 48%; + --sidebar-primary-foreground: 0 0% 100%; + --sidebar-accent: 240 3.7% 15.9%; + --sidebar-accent-foreground: 240 4.8% 95.9%; + --sidebar-border: 240 3.7% 15.9%; + --sidebar-ring: 217.2 91.2% 59.8%; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/app/icon.png b/app/icon.png new file mode 100644 index 0000000..d7ea0c6 Binary files /dev/null and b/app/icon.png differ diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..cd352e9 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,28 @@ +import type React from "react" +import type { Metadata } from "next" +import { Inter } from "next/font/google" +import "./globals.css" + +const inter = Inter({ subsets: ["latin"] }) + +export const metadata: Metadata = { + title: 'ממ"ד-אפ', + manifest: '/manifest.webmanifest', + description: 'דיווחים ומידע על מקלטים באזור שלך', + icons: { + icon: '/icon.png', + apple: '/apple-icon.png', + }, +} + +export default function RootLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + + {children} + + ) +} diff --git a/app/login/page.tsx b/app/login/page.tsx new file mode 100644 index 0000000..804c43a --- /dev/null +++ b/app/login/page.tsx @@ -0,0 +1,100 @@ +"use client" + +import type React from "react" + +import { useState } from "react" +import { useRouter } from "next/navigation" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Alert, AlertDescription } from "@/components/ui/alert" + +export default function LoginPage() { + const [nationalId, setNationalId] = useState("") + const [password, setPassword] = useState("") + const [error, setError] = useState("") + const [loading, setLoading] = useState(false) + const router = useRouter() + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setLoading(true) + setError("") + + try { + const response = await fetch("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ nationalId, password }), + }) + + const data = await response.json() + + if (response.ok) { + localStorage.setItem("user", JSON.stringify(data.user)) + + // Check if user must change password + if (data.user.must_change_password) { + router.push("/change-password") + } else { + router.push("/dashboard") + } + } else { + setError(data.error || "שגיאה בהתחברות") + } + } catch (err) { + setError("שגיאה בחיבור לשרת") + } finally { + setLoading(false) + } + } + + return ( +
+ + + התחברות לממ"ד-אפ + יש להזדהות על מנת להמשיך. + + +
+
+ + setNationalId(e.target.value)} + placeholder="9 ספרות" + maxLength={9} + required + className="text-right" + dir="rtl" + /> +
+
+ + setPassword(e.target.value)} + placeholder="הזינו סיסמה" + required + /> +
+ {error && ( + + {error} + + )} + +
+
+
+
+ ) +} diff --git a/app/manifest.webmanifest b/app/manifest.webmanifest new file mode 100644 index 0000000..1587dd5 --- /dev/null +++ b/app/manifest.webmanifest @@ -0,0 +1,23 @@ +{ + "name": "ממ\"ד-אפ", + "short_name": "ממ\"ד", + "description": "דיווחים בשעת חירום", + "start_url": "/", + "display": "standalone", + "background_color": "#f9fafb", + "theme_color": "#f9fafb", + "icons": [ + { + "src": "/icon.png", + "sizes": "1024x1024", + "type": "image/png" + }, + { + "src": "/apple-icon.png", + "sizes": "1024x1024", + "type": "image/png", + "purpose": "any" + } + ], + "lang": "he" +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..65d81c6 --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,25 @@ +"use client" + +import { useEffect } from "react" +import { useRouter } from "next/navigation" + +export default function HomePage() { + const router = useRouter() + + useEffect(() => { + const user = localStorage.getItem("user") + if (user) { + router.push("/dashboard") + } else { + router.push("/login") + } + }, [router]) + + return ( +
+
+

טוען...

+
+
+ ) +} diff --git a/app/role-admin/page.tsx b/app/role-admin/page.tsx new file mode 100644 index 0000000..1bf58b4 --- /dev/null +++ b/app/role-admin/page.tsx @@ -0,0 +1,409 @@ +"use client" + +import { useState, useEffect } from "react" +import { useRouter } from "next/navigation" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Input } from "@/components/ui/input" +import { Alert, AlertDescription } from "@/components/ui/alert" +import { ArrowRight, Users, UserCog, MessageSquare, KeyRound, Trash2, Home } from "lucide-react" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { RoleManagementModal } from "@/components/role-management-modal" +import { ReportOnBehalfModal } from "@/components/report-on-behalf-modal" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog" +import { type User, type UserRole, ROLE_NAMES } from "@/types/user" + +interface AdminUser { + national_id: string + name: string + role: UserRole + field?: string + department?: string + team?: string +} + +export default function RoleAdminPage() { + const [admin, setAdmin] = useState(null) + const [users, setUsers] = useState([]) + const [filteredUsers, setFilteredUsers] = useState([]) + const [searchTerm, setSearchTerm] = useState("") + const [message, setMessage] = useState("") + const [loading, setLoading] = useState(false) + const [roleModalOpen, setRoleModalOpen] = useState(false) + const [reportModalOpen, setReportModalOpen] = useState(false) + const [selectedUser, setSelectedUser] = useState(null) + const router = useRouter() + + useEffect(() => { + const userData = localStorage.getItem("user") + if (!userData) { + router.push("/login") + return + } + + const parsedUser = JSON.parse(userData) + if (parsedUser.role === "user") { + router.push("/dashboard") + return + } + + setAdmin(parsedUser) + fetchManageableUsers(parsedUser.national_id) + }, [router]) + + useEffect(() => { + if (searchTerm) { + setFilteredUsers(users.filter((user) => user.name.includes(searchTerm))) + } else { + setFilteredUsers(users) + } + }, [searchTerm, users]) + + const fetchManageableUsers = async (adminId: string) => { + setLoading(true) + try { + const response = await fetch("/api/admin/manageable-users", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ adminId }), + }) + + const data = await response.json() + if (response.ok) { + setUsers(data.users) + } else { + setMessage(data.error || "שגיאה בטעינת משתמשים") + } + } catch (err) { + setMessage("שגיאה בחיבור לשרת") + } finally { + setLoading(false) + } + } + + const handleRoleChange = async (userId: string, newRole: string) => { + try { + const response = await fetch("/api/admin/change-role", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + adminId: admin?.national_id, + targetUserId: userId, + newRole, + }), + }) + + const data = await response.json() + if (response.ok) { + setMessage(data.message) + fetchManageableUsers(admin?.national_id || "") + } else { + setMessage(data.error || "שגיאה בשינוי תפקיד") + } + } catch (err) { + setMessage("שגיאה בחיבור לשרת") + } + } + + const handleReportOnBehalf = async (userId: string, status: string) => { + try { + const response = await fetch("/api/admin/report-on-behalf", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + adminId: admin?.national_id, + targetUserId: userId, + status, + }), + }) + + const data = await response.json() + if (response.ok) { + setMessage(data.message) + fetchManageableUsers(admin?.national_id || "") + } else { + setMessage(data.error || "שגיאה בדיווח") + } + } catch (err) { + setMessage("שגיאה בחיבור לשרת") + } + } + + const handleDeleteUser = async (nationalId: string) => { + try { + const response = await fetch(`/api/admin/users/${nationalId}`, { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + adminId: admin?.national_id, + }), + }) + + if (response.ok) { + setMessage("משתמש נמחק בהצלחה") + fetchManageableUsers(admin?.national_id || "") + } else { + const data = await response.json() + setMessage(data.error || "שגיאה במחיקת משתמש") + } + } catch (err) { + setMessage("שגיאה בחיבור לשרת") + } + } + + const handleResetPassword = async (nationalId: string, userName: string) => { + try { + const response = await fetch("/api/admin/reset-password", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + adminId: admin?.national_id, + targetUserId: nationalId, + }), + }) + + if (response.ok) { + setMessage(`סיסמה אופסה בהצלחה עבור ${userName}. הסיסמה החדשה: password123`) + fetchManageableUsers(admin?.national_id || "") + } else { + const data = await response.json() + setMessage(data.error || "שגיאה באיפוס סיסמה") + } + } catch (err) { + setMessage("שגיאה בחיבור לשרת") + } + } + + const getStatusText = (status?: string) => { + switch (status) { + case "yes": + return { text: "במקלט/חדר מוגן", color: "text-green-600" } + case "no": + return { text: "לא במקלט", color: "text-orange-600" } + case "no_alarm": + return { text: "אין אזעקה", color: "text-blue-600" } + case "safe_after_exit": + return { text: "אני בטוח (אחרי יציאה מהמקלט)", color: "text-purple-600" } + default: + return { text: "לא דיווח", color: "text-gray-500" } + } + } + + const getScopeText = () => { + if (!admin) return "" + + switch (admin.role) { + case "global_admin": + return "כל המערכת" + case "field_admin": + return `תחום ${admin.field}` + case "department_admin": + return `מסגרת ${admin.department}` + case "team_admin": + return `צוות ${admin.team}` + default: + return "" + } + } + + if (!admin) return null + + return ( +
+
+ + +
+
+ ניהול תפקידים ודיווחים +

+ {ROLE_NAMES[admin.role]} - {getScopeText()} +

+
+
+ + +
+
+
+
+ + {message && ( + + {message} + + )} + + + + + + ניהול משתמשים ({filteredUsers.length}) + +
+ setSearchTerm(e.target.value)} + className="max-w-md" + /> +
+
+ + {loading ? ( +
טוען משתמשים...
+ ) : ( +
+ + + + שם + מזהה + תפקיד + תחום + מסגרת + צוות + פעולות + + + + {filteredUsers.map((user) => { + const status = getStatusText(user.in_shelter) + return ( + + {user.name} + + {user.national_id} + + + + {ROLE_NAMES[user.role] || user.role} + + + + + {user.field || "לא הוגדר"} + + + + + {user.department || "לא הוגדר"} + + + + + {user.team || "לא הוגדר"} + + + +
+ + + + + + + + האם אתה בטוח? + + פעולה זו בלתי הפיכה. האם אתה בטוח שברצונך למחוק את {user.name}? + + + + ביטול + { + if (user.national_id === admin?.national_id) { + setMessage("לא ניתן למחוק את עצמך") + return + } + handleDeleteUser(user.national_id) + }} + > + מחיקה + + + + + + + + + + + איפוס סיסמה + + האם אתה בטוח שברצונך לאפס את הסיסמה של {user.name}? הסיסמה החדשה תהיה: password123 + + + + ביטול + handleResetPassword(user.national_id, user.name)}> + איפוס סיסמה + + + + +
+
+
+ ) + })} +
+
+ {filteredUsers.length === 0 && ( +
+ {searchTerm ? "לא נמצאו משתמשים התואמים לחיפוש" : "אין משתמשים"} +
+ )} +
+ )} +
+
+ + setRoleModalOpen(false)} + user={selectedUser} + adminRole={admin.role} + onRoleChange={handleRoleChange} + /> + + setReportModalOpen(false)} + user={selectedUser} + onReport={handleReportOnBehalf} + /> +
+
+ ) +} diff --git a/components.json b/components.json new file mode 100644 index 0000000..d9ef0ae --- /dev/null +++ b/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} \ No newline at end of file diff --git a/components/database-monitor.tsx b/components/database-monitor.tsx new file mode 100644 index 0000000..3f96ed9 --- /dev/null +++ b/components/database-monitor.tsx @@ -0,0 +1,213 @@ +"use client" + +import { useState, useEffect } from "react" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Alert, AlertDescription } from "@/components/ui/alert" +import { RefreshCw, Database, Activity, AlertTriangle, CheckCircle } from "lucide-react" + +interface PoolStats { + totalConnections: number + freeConnections: number + acquiringConnections: number + connectionLimit: number +} + +interface HealthData { + status: string + responseTime?: number + poolStats?: PoolStats + recommendations?: string[] + error?: string + timestamp: string +} + +export function DatabaseMonitor() { + const [healthData, setHealthData] = useState(null) + const [loading, setLoading] = useState(false) + const [autoRefresh, setAutoRefresh] = useState(false) + + const fetchHealth = async () => { + setLoading(true) + try { + const response = await fetch("/api/admin/db-health") + const data = await response.json() + setHealthData(data) + } catch (error) { + console.error("Error fetching database health:", error) + setHealthData({ + status: "error", + error: "Failed to fetch health data", + timestamp: new Date().toISOString(), + }) + } finally { + setLoading(false) + } + } + + useEffect(() => { + fetchHealth() + }, []) + + useEffect(() => { + let interval: NodeJS.Timeout + if (autoRefresh) { + interval = setInterval(fetchHealth, 5000) // Refresh every 5 seconds + } + return () => { + if (interval) clearInterval(interval) + } + }, [autoRefresh]) + + const getStatusIcon = (status: string) => { + switch (status) { + case "healthy": + return + case "unhealthy": + return + default: + return + } + } + + const getUtilizationColor = (utilization: number) => { + if (utilization > 0.8) return "text-red-600" + if (utilization > 0.6) return "text-yellow-600" + return "text-green-600" + } + + return ( +
+ + +
+ + + מוניטור מסד נתונים + +
+ + +
+
+
+ + {healthData ? ( + <> + {/* Status Overview */} +
+ {getStatusIcon(healthData.status)} + סטטוס: {healthData.status === "healthy" ? "תקין" : "לא תקין"} + {healthData.responseTime && ( + ({healthData.responseTime}ms) + )} +
+ + {/* Pool Statistics */} + {healthData.poolStats && ( +
+
+
{healthData.poolStats.totalConnections}
+
חיבורים פעילים
+
+
+
{healthData.poolStats.freeConnections}
+
חיבורים זמינים
+
+
+
+ {healthData.poolStats.acquiringConnections} +
+
ממתינים לחיבור
+
+
+
{healthData.poolStats.connectionLimit}
+
מגבלת חיבורים
+
+
+ )} + + {/* Utilization Bar */} + {healthData.poolStats && ( +
+
+ ניצולת Pool + + {Math.round( + ((healthData.poolStats.totalConnections - healthData.poolStats.freeConnections) / + healthData.poolStats.connectionLimit) * + 100, + )} + % + +
+
+
+
+
+ )} + + {/* Recommendations */} + {healthData.recommendations && healthData.recommendations.length > 0 && ( + + + +
+ המלצות: +
    + {healthData.recommendations.map((rec, index) => ( +
  • + {rec} +
  • + ))} +
+
+
+
+ )} + + {/* Error Display */} + {healthData.error && ( + + + {healthData.error} + + )} + + {/* Timestamp */} +
+ עודכן: {new Date(healthData.timestamp).toLocaleString("he-IL")} +
+ + ) : ( +
טוען נתוני בריאות מסד הנתונים...
+ )} + + +
+ ) +} diff --git a/components/department-user-category-modal.tsx b/components/department-user-category-modal.tsx new file mode 100644 index 0000000..d22c75d --- /dev/null +++ b/components/department-user-category-modal.tsx @@ -0,0 +1,104 @@ +"use client" + +import { useState, useEffect } from "react" +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" + +interface User { + national_id: string + name: string + team?: string +} + +interface DepartmentUserCategoryModalProps { + isOpen: boolean + onClose: () => void + category: string + categoryName: string + adminId: string + departmentName: string + fieldName: string +} + +export function DepartmentUserCategoryModal({ + isOpen, + onClose, + category, + categoryName, + adminId, + departmentName, + fieldName, +}: DepartmentUserCategoryModalProps) { + const [users, setUsers] = useState([]) + const [loading, setLoading] = useState(false) + + useEffect(() => { + if (isOpen && category && adminId) { + fetchUsers() + } + }, [isOpen, category, adminId]) + + const fetchUsers = async () => { + setLoading(true) + try { + const response = await fetch("/api/admin/department-users-by-category", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ adminId, category }), + }) + const data = await response.json() + setUsers(data) + } catch (err) { + console.error("Error fetching department users:", err) + } finally { + setLoading(false) + } + } + + return ( + + + + + {categoryName} - מסגרת {departmentName} + + רשימת המשתמשים בקטגוריה זו מהמסגרת שלך + + + {loading ? ( +
טוען משתמשים...
+ ) : ( +
+ {users.length > 0 ? ( + + + + שם + צוות + + + + {users.map((user) => ( + + {user.name} + + + {user.team || "לא הוגדר"} + + + + ))} + +
+ ) : ( +
אין משתמשים בקטגוריה זו במסגרת שלך
+ )} +
+ סה"כ: {users.length} משתמשים ממסגרת {departmentName} +
+
+ )} +
+
+ ) +} diff --git a/components/field-user-category-modal.tsx b/components/field-user-category-modal.tsx new file mode 100644 index 0000000..9ae68f5 --- /dev/null +++ b/components/field-user-category-modal.tsx @@ -0,0 +1,109 @@ +"use client" + +import { useState, useEffect } from "react" +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" + +interface User { + national_id: string + name: string + department?: string + team?: string +} + +interface FieldUserCategoryModalProps { + isOpen: boolean + onClose: () => void + category: string + categoryName: string + adminId: string + fieldName: string +} + +export function FieldUserCategoryModal({ + isOpen, + onClose, + category, + categoryName, + adminId, + fieldName, +}: FieldUserCategoryModalProps) { + const [users, setUsers] = useState([]) + const [loading, setLoading] = useState(false) + + useEffect(() => { + if (isOpen && category && adminId) { + fetchUsers() + } + }, [isOpen, category, adminId]) + + const fetchUsers = async () => { + setLoading(true) + try { + const response = await fetch("/api/admin/field-users-by-category", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ adminId, category }), + }) + const data = await response.json() + setUsers(data) + } catch (err) { + console.error("Error fetching field users:", err) + } finally { + setLoading(false) + } + } + + return ( + + + + + {categoryName} - תחום {fieldName} + + רשימת המשתמשים בקטגוריה זו מהתחום שלך + + + {loading ? ( +
טוען משתמשים...
+ ) : ( +
+ {users.length > 0 ? ( + + + + שם + מסגרת + צוות + + + + {users.map((user) => ( + + {user.name} + + + {user.department || "לא הוגדר"} + + + + + {user.team || "לא הוגדר"} + + + + ))} + +
+ ) : ( +
אין משתמשים בקטגוריה זו בתחום שלך
+ )} +
+ סה"כ: {users.length} משתמשים מתחום {fieldName} +
+
+ )} +
+
+ ) +} diff --git a/components/report-on-behalf-modal.tsx b/components/report-on-behalf-modal.tsx new file mode 100644 index 0000000..17588cc --- /dev/null +++ b/components/report-on-behalf-modal.tsx @@ -0,0 +1,122 @@ +"use client" + +import { useState } from "react" +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Shield, ShieldAlert, ShieldX } from "lucide-react" + +interface User { + national_id: string + name: string + in_shelter?: string + last_updated?: string +} + +interface ReportOnBehalfModalProps { + isOpen: boolean + onClose: () => void + user: User | null + onReport: (userId: string, status: string) => void +} + +export function ReportOnBehalfModal({ isOpen, onClose, user, onReport }: ReportOnBehalfModalProps) { + const [loading, setLoading] = useState(false) + + if (!user) return null + + const handleReport = async (status: string) => { + setLoading(true) + try { + await onReport(user.national_id, status) + onClose() + } catch (error) { + console.error("Error reporting:", error) + } finally { + setLoading(false) + } + } + + const getStatusText = (status?: string) => { + switch (status) { + case "yes": + return "במקלט/חדר מוגן" + case "no": + return "לא במקלט" + case "no_alarm": + return "אין אזעקה" + case "safe_after_exit": + return "אני בטוח.ה (סוף אירוע)" + default: + return "אין דיווח" + } + } + + return ( + + + + דיווח עבור {user.name} + בחר סטטוס לדיווח עבור המשתמש + + +
+ {user.in_shelter && ( +
+

סטטוס נוכחי:

+

{getStatusText(user.in_shelter)}

+ {user.last_updated && ( +

עודכן: {new Date(user.last_updated).toLocaleString("he-IL")}

+ )} +
+ )} + +
+ + + + + + + +
+ + +
+
+
+ ) +} diff --git a/components/role-management-modal.tsx b/components/role-management-modal.tsx new file mode 100644 index 0000000..e238725 --- /dev/null +++ b/components/role-management-modal.tsx @@ -0,0 +1,92 @@ +"use client" + +import { useState } from "react" +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Alert, AlertDescription } from "@/components/ui/alert" +import { type User, type UserRole, ROLE_NAMES, ROLE_HIERARCHY } from "@/types/user" + +interface RoleManagementModalProps { + isOpen: boolean + onClose: () => void + user: User | null + adminRole: UserRole + onRoleChange: (userId: string, newRole: UserRole) => void +} + +export function RoleManagementModal({ isOpen, onClose, user, adminRole, onRoleChange }: RoleManagementModalProps) { + const [selectedRole, setSelectedRole] = useState("") + const [loading, setLoading] = useState(false) + + if (!user) return null + + const adminLevel = ROLE_HIERARCHY[adminRole] || 0 + const availableRoles = (Object.entries(ROLE_NAMES) as [UserRole, string][]).filter(([role]) => { + const roleLevel = ROLE_HIERARCHY[role] || 0 + return roleLevel < adminLevel + }) + + const handleRoleChange = async () => { + if (!selectedRole) return + + setLoading(true) + try { + await onRoleChange(user.national_id, selectedRole as UserRole) + onClose() + } catch (error) { + console.error("Error changing role:", error) + } finally { + setLoading(false) + } + } + + return ( + + + + שינוי תפקיד - {user.name} + בחר תפקיד חדש עבור המשתמש + + +
+
+

תפקיד נוכחי:

+

{ROLE_NAMES[user.role] || user.role}

+
+ +
+ + +
+ + {selectedRole && ( + + המשתמש יקבל הרשאות של {ROLE_NAMES[selectedRole as UserRole]} + + )} + +
+ + +
+
+
+
+ ) +} diff --git a/components/simple-pie-chart.tsx b/components/simple-pie-chart.tsx new file mode 100644 index 0000000..fab544b --- /dev/null +++ b/components/simple-pie-chart.tsx @@ -0,0 +1,107 @@ +"use client" + +interface Stats { + no_report: number + in_shelter: number + not_in_shelter: number + no_alarm: number +} + +interface SimplePieChartProps { + stats: Stats + onCategoryClick: (category: string, categoryName: string) => void +} + +export function SimplePieChart({ stats, onCategoryClick }: SimplePieChartProps) { + const data = [ + { name: "לא דיווחו", value: stats?.no_report || 0, color: "#ef4444", category: "no_report" }, + { name: "במקלט/חדר מוגן", value: stats?.in_shelter || 0, color: "#22c55e", category: "in_shelter" }, + { name: "לא במקלט", value: stats?.not_in_shelter || 0, color: "#f97316", category: "not_in_shelter" }, + { name: "אין אזעקה", value: stats?.no_alarm || 0, color: "#3b82f6", category: "no_alarm" }, + ] + + const total = data.reduce((sum, item) => sum + item.value, 0) + + if (total === 0) { + return ( +
+
+
📊
+

אין נתונים להצגה

+

הוסף משתמשים כדי לראות סטטיסטיקות

+
+
+ ) + } + + let currentAngle = 0 + + return ( +
+
+ + {data.map((item, index) => { + if (item.value === 0) return null + + const percentage = (item.value / total) * 100 + const angle = (item.value / total) * 360 + const startAngle = currentAngle + const endAngle = currentAngle + angle + + const startAngleRad = (startAngle * Math.PI) / 180 + const endAngleRad = (endAngle * Math.PI) / 180 + + const largeArcFlag = angle > 180 ? 1 : 0 + + const x1 = 96 + 80 * Math.cos(startAngleRad) + const y1 = 96 + 80 * Math.sin(startAngleRad) + const x2 = 96 + 80 * Math.cos(endAngleRad) + const y2 = 96 + 80 * Math.sin(endAngleRad) + + const pathData = [`M 96 96`, `L ${x1} ${y1}`, `A 80 80 0 ${largeArcFlag} 1 ${x2} ${y2}`, `Z`].join(" ") + + currentAngle += angle + + return ( + onCategoryClick(item.category, item.name)} + /> + ) + })} + + + {/* Center text */} +
+
+
{total}
+
סה"כ
+
+
+
+ + {/* Legend */} +
+ {data + .filter((item) => item.value > 0) + .map((item, index) => ( +
onCategoryClick(item.category, item.name)} + > +
+ + {item.name}: {item.value} + +
+ ))} +
+
+ ) +} diff --git a/components/stats-pie-chart.tsx b/components/stats-pie-chart.tsx new file mode 100644 index 0000000..32523f1 --- /dev/null +++ b/components/stats-pie-chart.tsx @@ -0,0 +1,165 @@ +"use client" + +import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend } from "recharts" + +interface Stats { + no_report: number + in_shelter: number + not_in_shelter: number + no_alarm: number + safe_after_exit: number +} + +interface StatsPieChartProps { + stats: Stats + onCategoryClick: (category: string, categoryName: string) => void +} + +const COLORS = { + no_report: "#ef4444", // red-500 + in_shelter: "#22c55e", // green-500 + not_in_shelter: "#f97316", // orange-500 + no_alarm: "#3b82f6", // blue-500 + safe_after_exit: "#10b981", +} + +export function StatsPieChart({ stats, onCategoryClick }: StatsPieChartProps) { + console.log("StatsPieChart received stats:", stats) + + // Create data array - always include all categories for testing + const data = [ + { + name: "לא דיווחו", + value: Number(stats?.no_report) || 0, + category: "no_report", + color: COLORS.no_report, + }, + { + name: "במקלט/חדר מוגן", + value: Number(stats?.in_shelter) || 0, + category: "in_shelter", + color: COLORS.in_shelter, + }, + { + name: "לא במקלט", + value: Number(stats?.not_in_shelter) || 0, + category: "not_in_shelter", + color: COLORS.not_in_shelter, + }, + { + name: "אין אזעקה", + value: Number(stats?.no_alarm) || 0, + category: "no_alarm", + color: COLORS.no_alarm, + }, + { + name: "אני בטוח.ה (סוף אירוע)", + value: Number(stats?.safe_after_exit) || 0, + category: "safe_after_exit", + color: COLORS.safe_after_exit, + } + ] + + console.log("Pie chart data:", data) + + // Calculate total for testing + const total = data.reduce((sum, item) => sum + item.value, 0) + console.log("Total value:", total) + + // If no real data, create sample data for testing + const displayData = + total === 0 + ? [ + { name: "לא דיווחו", value: 1, category: "no_report", color: COLORS.no_report }, + { name: "במקלט/חדר מוגן", value: 2, category: "in_shelter", color: COLORS.in_shelter }, + { name: "לא במקלט", value: 1, category: "not_in_shelter", color: COLORS.not_in_shelter }, + { name: "אין אזעקה", value: 3, category: "no_alarm", color: COLORS.no_alarm }, + { name: "אני בטוח.ה (סוף אירוע)", value: 1, category: "safe_after_exit", color: COLORS.safe_after_exit }, + ] + : data.filter((item) => item.value > 0) + + const CustomTooltip = ({ active, payload }: any) => { + if (active && payload && payload.length) { + const data = payload[0].payload + return ( +
+

{data.name}

+

כמות: {data.value}

+

לחץ לפירוט

+
+ ) + } + return null + } + + const handleClick = (data: any) => { + console.log("Pie chart clicked:", data) + if (data && data.category) { + onCategoryClick(data.category, data.name) + } + } + + const renderCustomLabel = ({ cx, cy, midAngle, innerRadius, outerRadius, value, name }: any) => { + const RADIAN = Math.PI / 180 + const radius = innerRadius + (outerRadius - innerRadius) * 0.5 + const x = cx + radius * Math.cos(-midAngle * RADIAN) + const y = cy + radius * Math.sin(-midAngle * RADIAN) + + return ( + cx ? "start" : "end"} + dominantBaseline="central" + fontSize={12} + fontWeight="bold" + style={{ textShadow: "1px 1px 2px rgba(0,0,0,0.7)" }} + > + {value} + + ) + } + + return ( +
+
+ {total === 0 ? "נתוני דוגמה (אין משתמשים)" : `סה"כ: ${total} משתמשים`} +
+ + + + + {displayData.map((entry, index) => ( + + ))} + + } /> + {value}} + /> + + + +
+ ) +} diff --git a/components/team-user-category-modal.tsx b/components/team-user-category-modal.tsx new file mode 100644 index 0000000..795f7fc --- /dev/null +++ b/components/team-user-category-modal.tsx @@ -0,0 +1,99 @@ +"use client" + +import { useState, useEffect } from "react" +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" + +interface User { + national_id: string + name: string +} + +interface TeamUserCategoryModalProps { + isOpen: boolean + onClose: () => void + category: string + categoryName: string + adminId: string + teamName: string + departmentName: string + fieldName: string +} + +export function TeamUserCategoryModal({ + isOpen, + onClose, + category, + categoryName, + adminId, + teamName, + departmentName, + fieldName, +}: TeamUserCategoryModalProps) { + const [users, setUsers] = useState([]) + const [loading, setLoading] = useState(false) + + useEffect(() => { + if (isOpen && category && adminId) { + fetchUsers() + } + }, [isOpen, category, adminId]) + + const fetchUsers = async () => { + setLoading(true) + try { + const response = await fetch("/api/admin/team-users-by-category", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ adminId, category }), + }) + const data = await response.json() + setUsers(data) + } catch (err) { + console.error("Error fetching team users:", err) + } finally { + setLoading(false) + } + } + + return ( + + + + + {categoryName} - צוות {teamName} + + רשימת המשתמשים בקטגוריה זו מהצוות שלך + + + {loading ? ( +
טוען משתמשים...
+ ) : ( +
+ {users.length > 0 ? ( + + + + שם + + + + {users.map((user) => ( + + {user.name} + + ))} + +
+ ) : ( +
אין משתמשים בקטגוריה זו בצוות שלך
+ )} +
+ סה"כ: {users.length} משתמשים מצוות {teamName} +
+
+ )} +
+
+ ) +} diff --git a/components/theme-provider.tsx b/components/theme-provider.tsx new file mode 100644 index 0000000..55c2f6e --- /dev/null +++ b/components/theme-provider.tsx @@ -0,0 +1,11 @@ +'use client' + +import * as React from 'react' +import { + ThemeProvider as NextThemesProvider, + type ThemeProviderProps, +} from 'next-themes' + +export function ThemeProvider({ children, ...props }: ThemeProviderProps) { + return {children} +} diff --git a/components/ui/accordion.tsx b/components/ui/accordion.tsx new file mode 100644 index 0000000..24c788c --- /dev/null +++ b/components/ui/accordion.tsx @@ -0,0 +1,58 @@ +"use client" + +import * as React from "react" +import * as AccordionPrimitive from "@radix-ui/react-accordion" +import { ChevronDown } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Accordion = AccordionPrimitive.Root + +const AccordionItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AccordionItem.displayName = "AccordionItem" + +const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + svg]:rotate-180", + className + )} + {...props} + > + {children} + + + +)) +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
{children}
+
+)) + +AccordionContent.displayName = AccordionPrimitive.Content.displayName + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } diff --git a/components/ui/alert-dialog.tsx b/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..25e7b47 --- /dev/null +++ b/components/ui/alert-dialog.tsx @@ -0,0 +1,141 @@ +"use client" + +import * as React from "react" +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" + +const AlertDialog = AlertDialogPrimitive.Root + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger + +const AlertDialogPortal = AlertDialogPrimitive.Portal + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)) +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogHeader.displayName = "AlertDialogHeader" + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogFooter.displayName = "AlertDialogFooter" + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/components/ui/alert.tsx b/components/ui/alert.tsx new file mode 100644 index 0000000..41fa7e0 --- /dev/null +++ b/components/ui/alert.tsx @@ -0,0 +1,59 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const alertVariants = cva( + "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)) +Alert.displayName = "Alert" + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertTitle.displayName = "AlertTitle" + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertDescription.displayName = "AlertDescription" + +export { Alert, AlertTitle, AlertDescription } diff --git a/components/ui/aspect-ratio.tsx b/components/ui/aspect-ratio.tsx new file mode 100644 index 0000000..d6a5226 --- /dev/null +++ b/components/ui/aspect-ratio.tsx @@ -0,0 +1,7 @@ +"use client" + +import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio" + +const AspectRatio = AspectRatioPrimitive.Root + +export { AspectRatio } diff --git a/components/ui/avatar.tsx b/components/ui/avatar.tsx new file mode 100644 index 0000000..51e507b --- /dev/null +++ b/components/ui/avatar.tsx @@ -0,0 +1,50 @@ +"use client" + +import * as React from "react" +import * as AvatarPrimitive from "@radix-ui/react-avatar" + +import { cn } from "@/lib/utils" + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Avatar.displayName = AvatarPrimitive.Root.displayName + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarImage.displayName = AvatarPrimitive.Image.displayName + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/components/ui/badge.tsx b/components/ui/badge.tsx new file mode 100644 index 0000000..f000e3e --- /dev/null +++ b/components/ui/badge.tsx @@ -0,0 +1,36 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ) +} + +export { Badge, badgeVariants } diff --git a/components/ui/breadcrumb.tsx b/components/ui/breadcrumb.tsx new file mode 100644 index 0000000..60e6c96 --- /dev/null +++ b/components/ui/breadcrumb.tsx @@ -0,0 +1,115 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { ChevronRight, MoreHorizontal } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Breadcrumb = React.forwardRef< + HTMLElement, + React.ComponentPropsWithoutRef<"nav"> & { + separator?: React.ReactNode + } +>(({ ...props }, ref) =>