Merge branch 'auth2' into main
This commit is contained in:
55
src/app/admin/audit-logs/page.js
Normal file
55
src/app/admin/audit-logs/page.js
Normal file
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import AuditLogViewer from "@/components/AuditLogViewer";
|
||||
|
||||
export default function AuditLogsPage() {
|
||||
const { data: session, status } = useSession();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "loading") return; // Still loading
|
||||
|
||||
if (!session) {
|
||||
router.push("/auth/signin");
|
||||
return;
|
||||
}
|
||||
|
||||
// Only allow admins and project managers to view audit logs
|
||||
if (!["admin", "project_manager"].includes(session.user.role)) {
|
||||
router.push("/");
|
||||
return;
|
||||
}
|
||||
}, [session, status, router]);
|
||||
|
||||
if (status === "loading") {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-gray-900"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!session || !["admin", "project_manager"].includes(session.user.role)) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-4">
|
||||
Access Denied
|
||||
</h1>
|
||||
<p className="text-gray-600">
|
||||
You don't have permission to view this page.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
<AuditLogViewer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
336
src/app/admin/users/[id]/edit/page.js
Normal file
336
src/app/admin/users/[id]/edit/page.js
Normal file
@@ -0,0 +1,336 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Card, CardHeader, CardContent } from "@/components/ui/Card";
|
||||
import Button from "@/components/ui/Button";
|
||||
import { Input } from "@/components/ui/Input";
|
||||
import PageContainer from "@/components/ui/PageContainer";
|
||||
import PageHeader from "@/components/ui/PageHeader";
|
||||
import { LoadingState } from "@/components/ui/States";
|
||||
|
||||
export default function EditUserPage() {
|
||||
const [user, setUser] = useState(null);
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
email: "",
|
||||
role: "user",
|
||||
is_active: true,
|
||||
password: ""
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
|
||||
const { data: session, status } = useSession();
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
|
||||
// Check if user is admin
|
||||
useEffect(() => {
|
||||
if (status === "loading") return;
|
||||
if (!session || session.user.role !== "admin") {
|
||||
router.push("/");
|
||||
return;
|
||||
}
|
||||
}, [session, status, router]);
|
||||
|
||||
// Fetch user data
|
||||
useEffect(() => {
|
||||
if (session?.user?.role === "admin" && params.id) {
|
||||
fetchUser();
|
||||
}
|
||||
}, [session, params.id]);
|
||||
|
||||
const fetchUser = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch(`/api/admin/users/${params.id}`);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
setError("User not found");
|
||||
return;
|
||||
}
|
||||
throw new Error("Failed to fetch user");
|
||||
}
|
||||
|
||||
const userData = await response.json();
|
||||
setUser(userData);
|
||||
setFormData({
|
||||
name: userData.name,
|
||||
email: userData.email,
|
||||
role: userData.role,
|
||||
is_active: userData.is_active,
|
||||
password: "" // Never populate password field
|
||||
});
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setError("");
|
||||
setSuccess("");
|
||||
|
||||
try {
|
||||
// Prepare update data (exclude empty password)
|
||||
const updateData = {
|
||||
name: formData.name,
|
||||
email: formData.email,
|
||||
role: formData.role,
|
||||
is_active: formData.is_active
|
||||
};
|
||||
|
||||
// Only include password if it's provided
|
||||
if (formData.password.trim()) {
|
||||
if (formData.password.length < 6) {
|
||||
throw new Error("Password must be at least 6 characters long");
|
||||
}
|
||||
updateData.password = formData.password;
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/admin/users/${params.id}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(updateData),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || "Failed to update user");
|
||||
}
|
||||
|
||||
const updatedUser = await response.json();
|
||||
setUser(updatedUser);
|
||||
setSuccess("User updated successfully");
|
||||
|
||||
// Clear password field after successful update
|
||||
setFormData(prev => ({ ...prev, password: "" }));
|
||||
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (status === "loading" || !session) {
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
if (session.user.role !== "admin") {
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className="text-center py-12">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">Access Denied</h2>
|
||||
<p className="text-gray-600 mb-6">You need admin privileges to access this page.</p>
|
||||
<Link href="/">
|
||||
<Button>Go Home</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
if (error && !user) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className="text-center py-12">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">Error</h2>
|
||||
<p className="text-gray-600 mb-6">{error}</p>
|
||||
<Link href="/admin/users">
|
||||
<Button>Back to Users</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title={`Edit User: ${user?.name}`}
|
||||
description="Update user information and permissions"
|
||||
>
|
||||
<Link href="/admin/users">
|
||||
<Button variant="outline">
|
||||
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
Back to Users
|
||||
</Button>
|
||||
</Link>
|
||||
</PageHeader>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-md">
|
||||
<p className="text-red-600">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="mb-6 p-4 bg-green-50 border border-green-200 rounded-md">
|
||||
<p className="text-green-600">{success}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">User Information</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Name *
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Email *
|
||||
</label>
|
||||
<Input
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Role *
|
||||
</label>
|
||||
<select
|
||||
value={formData.role}
|
||||
onChange={(e) => setFormData({ ...formData, role: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
required
|
||||
>
|
||||
<option value="read_only">Read Only</option>
|
||||
<option value="user">User</option>
|
||||
<option value="project_manager">Project Manager</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
New Password
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
placeholder="Leave blank to keep current password"
|
||||
minLength={6}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Leave blank to keep the current password
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="is_active"
|
||||
checked={formData.is_active}
|
||||
onChange={(e) => setFormData({ ...formData, is_active: e.target.checked })}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
disabled={user?.id === session?.user?.id}
|
||||
/>
|
||||
<label htmlFor="is_active" className="ml-2 block text-sm text-gray-900">
|
||||
Active User
|
||||
{user?.id === session?.user?.id && (
|
||||
<span className="text-gray-500 ml-1">(Cannot deactivate your own account)</span>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-4 pt-6 border-t border-gray-200">
|
||||
<Button type="submit" disabled={saving}>
|
||||
{saving ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
<Link href="/admin/users">
|
||||
<Button type="button" variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* User Details Card */}
|
||||
{user && (
|
||||
<Card className="mt-6">
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">Account Details</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-500">Created</p>
|
||||
<p className="text-sm text-gray-900">{new Date(user.created_at).toLocaleDateString()}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-500">Last Updated</p>
|
||||
<p className="text-sm text-gray-900">
|
||||
{user.updated_at ? new Date(user.updated_at).toLocaleDateString() : "Never"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-500">Last Login</p>
|
||||
<p className="text-sm text-gray-900">
|
||||
{user.last_login ? new Date(user.last_login).toLocaleDateString() : "Never"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-500">Failed Login Attempts</p>
|
||||
<p className="text-sm text-gray-900">{user.failed_login_attempts || 0}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-500">Account Status</p>
|
||||
<p className="text-sm text-gray-900">
|
||||
{user.is_active ? "Active" : "Inactive"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-500">Account Locked</p>
|
||||
<p className="text-sm text-gray-900">
|
||||
{user.locked_until && new Date(user.locked_until) > new Date()
|
||||
? `Until ${new Date(user.locked_until).toLocaleDateString()}`
|
||||
: "No"
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
418
src/app/admin/users/page.js
Normal file
418
src/app/admin/users/page.js
Normal file
@@ -0,0 +1,418 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Card, CardHeader, CardContent } from "@/components/ui/Card";
|
||||
import Button from "@/components/ui/Button";
|
||||
import Badge from "@/components/ui/Badge";
|
||||
import { Input } from "@/components/ui/Input";
|
||||
import PageContainer from "@/components/ui/PageContainer";
|
||||
import PageHeader from "@/components/ui/PageHeader";
|
||||
import { LoadingState } from "@/components/ui/States";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
|
||||
export default function UserManagementPage() {
|
||||
const [users, setUsers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const { data: session, status } = useSession();
|
||||
const router = useRouter();
|
||||
|
||||
// Check if user is admin
|
||||
useEffect(() => {
|
||||
if (status === "loading") return;
|
||||
if (!session || session.user.role !== "admin") {
|
||||
router.push("/");
|
||||
return;
|
||||
}
|
||||
}, [session, status, router]);
|
||||
|
||||
// Fetch users
|
||||
useEffect(() => {
|
||||
if (session?.user?.role === "admin") {
|
||||
fetchUsers();
|
||||
}
|
||||
}, [session]);
|
||||
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch("/api/admin/users");
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch users");
|
||||
}
|
||||
const data = await response.json();
|
||||
setUsers(data);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteUser = async (userId) => {
|
||||
if (!confirm("Are you sure you want to delete this user?")) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/admin/users/${userId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to delete user");
|
||||
}
|
||||
|
||||
setUsers(users.filter(user => user.id !== userId));
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleUser = async (userId, isActive) => {
|
||||
try {
|
||||
const response = await fetch(`/api/admin/users/${userId}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ is_active: !isActive }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to update user");
|
||||
}
|
||||
|
||||
setUsers(users.map(user =>
|
||||
user.id === userId
|
||||
? { ...user, is_active: !isActive }
|
||||
: user
|
||||
));
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const getRoleColor = (role) => {
|
||||
switch (role) {
|
||||
case "admin":
|
||||
return "red";
|
||||
case "project_manager":
|
||||
return "blue";
|
||||
case "user":
|
||||
return "green";
|
||||
case "read_only":
|
||||
return "gray";
|
||||
default:
|
||||
return "gray";
|
||||
}
|
||||
};
|
||||
|
||||
const getRoleDisplay = (role) => {
|
||||
switch (role) {
|
||||
case "project_manager":
|
||||
return "Project Manager";
|
||||
case "read_only":
|
||||
return "Read Only";
|
||||
default:
|
||||
return role.charAt(0).toUpperCase() + role.slice(1);
|
||||
}
|
||||
};
|
||||
|
||||
if (status === "loading" || !session) {
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
if (session.user.role !== "admin") {
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className="text-center py-12">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">Access Denied</h2>
|
||||
<p className="text-gray-600 mb-6">You need admin privileges to access this page.</p>
|
||||
<Link href="/">
|
||||
<Button>Go Home</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader title="User Management" description="Manage system users and permissions">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => setShowCreateForm(true)}
|
||||
>
|
||||
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Add User
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-md">
|
||||
<p className="text-red-600">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<LoadingState />
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Users List */}
|
||||
<div className="grid gap-6">
|
||||
{users.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<div className="text-center py-12">
|
||||
<svg className="w-16 h-16 mx-auto text-gray-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z" />
|
||||
</svg>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">No Users Found</h3>
|
||||
<p className="text-gray-500">Start by creating your first user.</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
users.map((user) => (
|
||||
<Card key={user.id}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-10 h-10 bg-gray-200 rounded-full flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">{user.name}</h3>
|
||||
<p className="text-sm text-gray-500">{user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge color={getRoleColor(user.role)}>
|
||||
{getRoleDisplay(user.role)}
|
||||
</Badge>
|
||||
<Badge color={user.is_active ? "green" : "red"}>
|
||||
{user.is_active ? "Active" : "Inactive"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-500">Created</p>
|
||||
<p className="text-sm text-gray-900">{formatDate(user.created_at)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-500">Last Login</p>
|
||||
<p className="text-sm text-gray-900">
|
||||
{user.last_login ? formatDate(user.last_login) : "Never"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-500">Failed Attempts</p>
|
||||
<p className="text-sm text-gray-900">{user.failed_login_attempts || 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{user.locked_until && new Date(user.locked_until) > new Date() && (
|
||||
<div className="mb-4 p-3 bg-yellow-50 border border-yellow-200 rounded-md">
|
||||
<p className="text-sm text-yellow-800">
|
||||
Account locked until {formatDate(user.locked_until)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleToggleUser(user.id, user.is_active)}
|
||||
>
|
||||
{user.is_active ? "Deactivate" : "Activate"}
|
||||
</Button>
|
||||
<Link href={`/admin/users/${user.id}/edit`}>
|
||||
<Button variant="outline" size="sm">
|
||||
Edit
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteUser(user.id)}
|
||||
disabled={user.id === session?.user?.id}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create User Modal/Form */}
|
||||
{showCreateForm && (
|
||||
<CreateUserModal
|
||||
onClose={() => setShowCreateForm(false)}
|
||||
onUserCreated={(newUser) => {
|
||||
setUsers([...users, newUser]);
|
||||
setShowCreateForm(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// Create User Modal Component
|
||||
function CreateUserModal({ onClose, onUserCreated }) {
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
email: "",
|
||||
password: "",
|
||||
role: "user",
|
||||
is_active: true
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/admin/users", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(formData),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || "Failed to create user");
|
||||
}
|
||||
|
||||
const newUser = await response.json();
|
||||
onUserCreated(newUser);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-white rounded-lg max-w-md w-full p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold">Create New User</h3>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-md">
|
||||
<p className="text-red-600 text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Name
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Email
|
||||
</label>
|
||||
<Input
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Password
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Role
|
||||
</label>
|
||||
<select
|
||||
value={formData.role}
|
||||
onChange={(e) => setFormData({ ...formData, role: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="read_only">Read Only</option>
|
||||
<option value="user">User</option>
|
||||
<option value="project_manager">Project Manager</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="is_active"
|
||||
checked={formData.is_active}
|
||||
onChange={(e) => setFormData({ ...formData, is_active: e.target.checked })}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="is_active" className="ml-2 block text-sm text-gray-900">
|
||||
Active User
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-3 pt-4">
|
||||
<Button type="submit" disabled={loading} className="flex-1">
|
||||
{loading ? "Creating..." : "Create User"}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={onClose} className="flex-1">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
129
src/app/api/admin/users/[id]/route.js
Normal file
129
src/app/api/admin/users/[id]/route.js
Normal file
@@ -0,0 +1,129 @@
|
||||
import { getUserById, updateUser, deleteUser } from "@/lib/userManagement.js";
|
||||
import { NextResponse } from "next/server";
|
||||
import { withAdminAuth } from "@/lib/middleware/auth";
|
||||
|
||||
// GET: Get user by ID (admin only)
|
||||
async function getUserHandler(req, { params }) {
|
||||
try {
|
||||
const user = getUserById(params.id);
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{ error: "User not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Remove password hash from response
|
||||
const { password_hash, ...safeUser } = user;
|
||||
return NextResponse.json(safeUser);
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error fetching user:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch user" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// PUT: Update user (admin only)
|
||||
async function updateUserHandler(req, { params }) {
|
||||
try {
|
||||
const data = await req.json();
|
||||
const userId = params.id;
|
||||
|
||||
// Prevent admin from deactivating themselves
|
||||
if (data.is_active === false && userId === req.user.id) {
|
||||
return NextResponse.json(
|
||||
{ error: "You cannot deactivate your own account" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate role if provided
|
||||
if (data.role) {
|
||||
const validRoles = ["read_only", "user", "project_manager", "admin"];
|
||||
if (!validRoles.includes(data.role)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid role specified" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate password length if provided
|
||||
if (data.password && data.password.length < 6) {
|
||||
return NextResponse.json(
|
||||
{ error: "Password must be at least 6 characters long" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const updatedUser = await updateUser(userId, data);
|
||||
|
||||
if (!updatedUser) {
|
||||
return NextResponse.json(
|
||||
{ error: "User not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Remove password hash from response
|
||||
const { password_hash, ...safeUser } = updatedUser;
|
||||
return NextResponse.json(safeUser);
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error updating user:", error);
|
||||
|
||||
if (error.message.includes("already exists")) {
|
||||
return NextResponse.json(
|
||||
{ error: "A user with this email already exists" },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to update user" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE: Delete user (admin only)
|
||||
async function deleteUserHandler(req, { params }) {
|
||||
try {
|
||||
const userId = params.id;
|
||||
|
||||
// Prevent admin from deleting themselves
|
||||
if (userId === req.user.id) {
|
||||
return NextResponse.json(
|
||||
{ error: "You cannot delete your own account" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const success = await deleteUser(userId);
|
||||
|
||||
if (!success) {
|
||||
return NextResponse.json(
|
||||
{ error: "User not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ message: "User deleted successfully" });
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error deleting user:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to delete user" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Protected routes - require admin authentication
|
||||
export const GET = withAdminAuth(getUserHandler);
|
||||
export const PUT = withAdminAuth(updateUserHandler);
|
||||
export const DELETE = withAdminAuth(deleteUserHandler);
|
||||
85
src/app/api/admin/users/route.js
Normal file
85
src/app/api/admin/users/route.js
Normal file
@@ -0,0 +1,85 @@
|
||||
import { getAllUsers, createUser } from "@/lib/userManagement.js";
|
||||
import { NextResponse } from "next/server";
|
||||
import { withAdminAuth } from "@/lib/middleware/auth";
|
||||
|
||||
// GET: Get all users (admin only)
|
||||
async function getUsersHandler(req) {
|
||||
try {
|
||||
const users = getAllUsers();
|
||||
// Remove password hashes from response
|
||||
const safeUsers = users.map(user => {
|
||||
const { password_hash, ...safeUser } = user;
|
||||
return safeUser;
|
||||
});
|
||||
return NextResponse.json(safeUsers);
|
||||
} catch (error) {
|
||||
console.error("Error fetching users:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch users" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// POST: Create new user (admin only)
|
||||
async function createUserHandler(req) {
|
||||
try {
|
||||
const data = await req.json();
|
||||
|
||||
// Validate required fields
|
||||
if (!data.name || !data.email || !data.password) {
|
||||
return NextResponse.json(
|
||||
{ error: "Name, email, and password are required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate password length
|
||||
if (data.password.length < 6) {
|
||||
return NextResponse.json(
|
||||
{ error: "Password must be at least 6 characters long" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate role
|
||||
const validRoles = ["read_only", "user", "project_manager", "admin"];
|
||||
if (data.role && !validRoles.includes(data.role)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid role specified" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const newUser = await createUser({
|
||||
name: data.name,
|
||||
email: data.email,
|
||||
password: data.password,
|
||||
role: data.role || "user",
|
||||
is_active: data.is_active !== undefined ? data.is_active : true
|
||||
});
|
||||
|
||||
// Remove password hash from response
|
||||
const { password_hash, ...safeUser } = newUser;
|
||||
return NextResponse.json(safeUser, { status: 201 });
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error creating user:", error);
|
||||
|
||||
if (error.message.includes("already exists")) {
|
||||
return NextResponse.json(
|
||||
{ error: "A user with this email already exists" },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to create user" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Protected routes - require admin authentication
|
||||
export const GET = withAdminAuth(getUsersHandler);
|
||||
export const POST = withAdminAuth(createUserHandler);
|
||||
@@ -1,8 +1,9 @@
|
||||
import { getAllProjectTasks } from "@/lib/queries/tasks";
|
||||
import { NextResponse } from "next/server";
|
||||
import { withReadAuth } from "@/lib/middleware/auth";
|
||||
|
||||
// GET: Get all project tasks across all projects
|
||||
export async function GET() {
|
||||
async function getAllProjectTasksHandler() {
|
||||
try {
|
||||
const tasks = getAllProjectTasks();
|
||||
return NextResponse.json(tasks);
|
||||
@@ -13,3 +14,6 @@ export async function GET() {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Protected routes - require authentication
|
||||
export const GET = withReadAuth(getAllProjectTasksHandler);
|
||||
|
||||
49
src/app/api/audit-logs/log/route.js
Normal file
49
src/app/api/audit-logs/log/route.js
Normal file
@@ -0,0 +1,49 @@
|
||||
// Force this API route to use Node.js runtime for database access
|
||||
export const runtime = "nodejs";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { logAuditEvent } from "@/lib/auditLog";
|
||||
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const data = await request.json();
|
||||
|
||||
const {
|
||||
action,
|
||||
userId,
|
||||
resourceType,
|
||||
resourceId,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
details,
|
||||
timestamp,
|
||||
} = data;
|
||||
|
||||
if (!action) {
|
||||
return NextResponse.json(
|
||||
{ error: "Action is required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Log the audit event
|
||||
await logAuditEvent({
|
||||
action,
|
||||
userId,
|
||||
resourceType,
|
||||
resourceId,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
details,
|
||||
timestamp,
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Audit log API error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
67
src/app/api/audit-logs/route.js
Normal file
67
src/app/api/audit-logs/route.js
Normal file
@@ -0,0 +1,67 @@
|
||||
// Force this API route to use Node.js runtime
|
||||
export const runtime = "nodejs";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { getAuditLogs, getAuditLogStats } from "@/lib/auditLog";
|
||||
|
||||
export async function GET(request) {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Only admins and project managers can view audit logs
|
||||
if (!["admin", "project_manager"].includes(session.user.role)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
|
||||
// Parse query parameters
|
||||
const filters = {
|
||||
userId: searchParams.get("userId") || null,
|
||||
action: searchParams.get("action") || null,
|
||||
resourceType: searchParams.get("resourceType") || null,
|
||||
resourceId: searchParams.get("resourceId") || null,
|
||||
startDate: searchParams.get("startDate") || null,
|
||||
endDate: searchParams.get("endDate") || null,
|
||||
limit: parseInt(searchParams.get("limit")) || 100,
|
||||
offset: parseInt(searchParams.get("offset")) || 0,
|
||||
orderBy: searchParams.get("orderBy") || "timestamp",
|
||||
orderDirection: searchParams.get("orderDirection") || "DESC",
|
||||
};
|
||||
|
||||
// Get audit logs
|
||||
const logs = await getAuditLogs(filters);
|
||||
|
||||
// Get statistics if requested
|
||||
const includeStats = searchParams.get("includeStats") === "true";
|
||||
let stats = null;
|
||||
|
||||
if (includeStats) {
|
||||
stats = await getAuditLogStats({
|
||||
startDate: filters.startDate,
|
||||
endDate: filters.endDate,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: logs,
|
||||
stats,
|
||||
filters: {
|
||||
...filters,
|
||||
total: logs.length,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Audit logs API error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
41
src/app/api/audit-logs/stats/route.js
Normal file
41
src/app/api/audit-logs/stats/route.js
Normal file
@@ -0,0 +1,41 @@
|
||||
// Force this API route to use Node.js runtime
|
||||
export const runtime = "nodejs";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { getAuditLogStats } from "@/lib/auditLog";
|
||||
|
||||
export async function GET(request) {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Only admins and project managers can view audit log statistics
|
||||
if (!["admin", "project_manager"].includes(session.user.role)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
|
||||
const filters = {
|
||||
startDate: searchParams.get("startDate") || null,
|
||||
endDate: searchParams.get("endDate") || null,
|
||||
};
|
||||
|
||||
const stats = await getAuditLogStats(filters);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: stats,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Audit log stats API error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
3
src/app/api/auth/[...nextauth]/route.js
Normal file
3
src/app/api/auth/[...nextauth]/route.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import { handlers } from "@/lib/auth"
|
||||
|
||||
export const { GET, POST } = handlers
|
||||
@@ -1,7 +1,8 @@
|
||||
import db from "@/lib/db";
|
||||
import { NextResponse } from "next/server";
|
||||
import { withReadAuth, withUserAuth } from "@/lib/middleware/auth";
|
||||
|
||||
export async function GET(req, { params }) {
|
||||
async function getContractHandler(req, { params }) {
|
||||
const { id } = await params;
|
||||
|
||||
const contract = db
|
||||
@@ -20,7 +21,7 @@ export async function GET(req, { params }) {
|
||||
return NextResponse.json(contract);
|
||||
}
|
||||
|
||||
export async function DELETE(req, { params }) {
|
||||
async function deleteContractHandler(req, { params }) {
|
||||
const { id } = params;
|
||||
|
||||
try {
|
||||
@@ -57,3 +58,7 @@ export async function DELETE(req, { params }) {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Protected routes - require authentication
|
||||
export const GET = withReadAuth(getContractHandler);
|
||||
export const DELETE = withUserAuth(deleteContractHandler);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import db from "@/lib/db";
|
||||
import { NextResponse } from "next/server";
|
||||
import { withReadAuth, withUserAuth } from "@/lib/middleware/auth";
|
||||
|
||||
export async function GET() {
|
||||
async function getContractsHandler() {
|
||||
const contracts = db
|
||||
.prepare(
|
||||
`
|
||||
@@ -21,7 +22,7 @@ export async function GET() {
|
||||
return NextResponse.json(contracts);
|
||||
}
|
||||
|
||||
export async function POST(req) {
|
||||
async function createContractHandler(req) {
|
||||
const data = await req.json();
|
||||
db.prepare(
|
||||
`
|
||||
@@ -46,3 +47,7 @@ export async function POST(req) {
|
||||
);
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
// Protected routes - require authentication
|
||||
export const GET = withReadAuth(getContractsHandler);
|
||||
export const POST = withUserAuth(createContractHandler);
|
||||
|
||||
37
src/app/api/debug-auth/route.js
Normal file
37
src/app/api/debug-auth/route.js
Normal file
@@ -0,0 +1,37 @@
|
||||
import { auth } from "@/lib/auth"
|
||||
import { NextResponse } from "next/server"
|
||||
|
||||
export const GET = auth(async (req) => {
|
||||
try {
|
||||
console.log("=== DEBUG AUTH ENDPOINT ===")
|
||||
console.log("Request URL:", req.url)
|
||||
console.log("Auth object:", req.auth)
|
||||
|
||||
if (!req.auth?.user) {
|
||||
return NextResponse.json({
|
||||
error: "No session found",
|
||||
debug: {
|
||||
hasAuth: !!req.auth,
|
||||
authKeys: req.auth ? Object.keys(req.auth) : [],
|
||||
}
|
||||
}, { status: 401 })
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
message: "Authenticated",
|
||||
user: req.auth.user,
|
||||
debug: {
|
||||
authKeys: Object.keys(req.auth),
|
||||
userKeys: Object.keys(req.auth.user)
|
||||
}
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error("Auth debug error:", error)
|
||||
return NextResponse.json({
|
||||
error: "Auth error",
|
||||
message: error.message,
|
||||
stack: error.stack
|
||||
}, { status: 500 })
|
||||
}
|
||||
})
|
||||
@@ -1,32 +1,82 @@
|
||||
// Force this API route to use Node.js runtime for database access
|
||||
export const runtime = "nodejs";
|
||||
|
||||
import db from "@/lib/db";
|
||||
import { NextResponse } from "next/server";
|
||||
import { withUserAuth } from "@/lib/middleware/auth";
|
||||
import {
|
||||
logApiActionSafe,
|
||||
AUDIT_ACTIONS,
|
||||
RESOURCE_TYPES,
|
||||
} from "@/lib/auditLogSafe.js";
|
||||
|
||||
export async function POST(req) {
|
||||
async function createNoteHandler(req) {
|
||||
const { project_id, task_id, note } = await req.json();
|
||||
|
||||
if (!note || (!project_id && !task_id)) {
|
||||
return NextResponse.json({ error: "Missing fields" }, { status: 400 });
|
||||
}
|
||||
|
||||
db.prepare(
|
||||
try {
|
||||
const result = db
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO notes (project_id, task_id, note, created_by, note_date)
|
||||
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
`
|
||||
INSERT INTO notes (project_id, task_id, note)
|
||||
VALUES (?, ?, ?)
|
||||
`
|
||||
).run(project_id || null, task_id || null, note);
|
||||
)
|
||||
.run(project_id || null, task_id || null, note, req.user?.id || null);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
// Log note creation
|
||||
await logApiActionSafe(
|
||||
req,
|
||||
AUDIT_ACTIONS.NOTE_CREATE,
|
||||
RESOURCE_TYPES.NOTE,
|
||||
result.lastInsertRowid.toString(),
|
||||
req.auth, // Use req.auth instead of req.session
|
||||
{
|
||||
noteData: { project_id, task_id, note_length: note.length },
|
||||
}
|
||||
);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Error creating note:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to create note", details: error.message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(_, { params }) {
|
||||
async function deleteNoteHandler(req, { params }) {
|
||||
const { id } = params;
|
||||
|
||||
// Get note data before deletion for audit log
|
||||
const note = db.prepare("SELECT * FROM notes WHERE note_id = ?").get(id);
|
||||
|
||||
db.prepare("DELETE FROM notes WHERE note_id = ?").run(id);
|
||||
|
||||
// Log note deletion
|
||||
await logApiActionSafe(
|
||||
req,
|
||||
AUDIT_ACTIONS.NOTE_DELETE,
|
||||
RESOURCE_TYPES.NOTE,
|
||||
id,
|
||||
req.auth, // Use req.auth instead of req.session
|
||||
{
|
||||
deletedNote: {
|
||||
project_id: note?.project_id,
|
||||
task_id: note?.task_id,
|
||||
note_length: note?.note?.length || 0,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
export async function PUT(req, { params }) {
|
||||
async function updateNoteHandler(req, { params }) {
|
||||
const noteId = params.id;
|
||||
const { note } = await req.json();
|
||||
|
||||
@@ -34,11 +84,40 @@ export async function PUT(req, { params }) {
|
||||
return NextResponse.json({ error: "Missing note or ID" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get original note for audit log
|
||||
const originalNote = db
|
||||
.prepare("SELECT * FROM notes WHERE note_id = ?")
|
||||
.get(noteId);
|
||||
|
||||
db.prepare(
|
||||
`
|
||||
UPDATE notes SET note = ? WHERE note_id = ?
|
||||
`
|
||||
).run(note, noteId);
|
||||
|
||||
// Log note update
|
||||
await logApiActionSafe(
|
||||
req,
|
||||
AUDIT_ACTIONS.NOTE_UPDATE,
|
||||
RESOURCE_TYPES.NOTE,
|
||||
noteId,
|
||||
req.auth, // Use req.auth instead of req.session
|
||||
{
|
||||
originalNote: {
|
||||
note_length: originalNote?.note?.length || 0,
|
||||
project_id: originalNote?.project_id,
|
||||
task_id: originalNote?.task_id,
|
||||
},
|
||||
updatedNote: {
|
||||
note_length: note.length,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
// Protected routes - require authentication
|
||||
export const POST = withUserAuth(createNoteHandler);
|
||||
export const DELETE = withUserAuth(deleteNoteHandler);
|
||||
export const PUT = withUserAuth(updateNoteHandler);
|
||||
|
||||
@@ -3,9 +3,10 @@ import {
|
||||
deleteProjectTask,
|
||||
} from "@/lib/queries/tasks";
|
||||
import { NextResponse } from "next/server";
|
||||
import { withUserAuth } from "@/lib/middleware/auth";
|
||||
|
||||
// PATCH: Update project task status
|
||||
export async function PATCH(req, { params }) {
|
||||
async function updateProjectTaskHandler(req, { params }) {
|
||||
try {
|
||||
const { status } = await req.json();
|
||||
|
||||
@@ -16,18 +17,19 @@ export async function PATCH(req, { params }) {
|
||||
);
|
||||
}
|
||||
|
||||
updateProjectTaskStatus(params.id, status);
|
||||
updateProjectTaskStatus(params.id, status, req.user?.id || null);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Error updating task status:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to update project task" },
|
||||
{ error: "Failed to update project task", details: error.message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE: Delete a project task
|
||||
export async function DELETE(req, { params }) {
|
||||
async function deleteProjectTaskHandler(req, { params }) {
|
||||
try {
|
||||
deleteProjectTask(params.id);
|
||||
return NextResponse.json({ success: true });
|
||||
@@ -38,3 +40,7 @@ export async function DELETE(req, { params }) {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Protected routes - require authentication
|
||||
export const PATCH = withUserAuth(updateProjectTaskHandler);
|
||||
export const DELETE = withUserAuth(deleteProjectTaskHandler);
|
||||
|
||||
@@ -5,9 +5,10 @@ import {
|
||||
} from "@/lib/queries/tasks";
|
||||
import { NextResponse } from "next/server";
|
||||
import db from "@/lib/db";
|
||||
import { withReadAuth, withUserAuth } from "@/lib/middleware/auth";
|
||||
|
||||
// GET: Get all project tasks or task templates based on query params
|
||||
export async function GET(req) {
|
||||
async function getProjectTasksHandler(req) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const projectId = searchParams.get("project_id");
|
||||
|
||||
@@ -23,7 +24,7 @@ export async function GET(req) {
|
||||
}
|
||||
|
||||
// POST: Create a new project task
|
||||
export async function POST(req) {
|
||||
async function createProjectTaskHandler(req) {
|
||||
try {
|
||||
const data = await req.json();
|
||||
|
||||
@@ -42,11 +43,20 @@ export async function POST(req) {
|
||||
);
|
||||
}
|
||||
|
||||
const result = createProjectTask(data);
|
||||
// Add user tracking information from authenticated session
|
||||
const taskData = {
|
||||
...data,
|
||||
created_by: req.user?.id || null,
|
||||
// If no assigned_to is specified, default to the creator
|
||||
assigned_to: data.assigned_to || req.user?.id || null,
|
||||
};
|
||||
|
||||
const result = createProjectTask(taskData);
|
||||
return NextResponse.json({ success: true, id: result.lastInsertRowid });
|
||||
} catch (error) {
|
||||
console.error("Error creating project task:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to create project task" },
|
||||
{ error: "Failed to create project task", details: error.message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
@@ -113,3 +123,7 @@ export async function PATCH(req) {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Protected routes - require authentication
|
||||
export const GET = withReadAuth(getProjectTasksHandler);
|
||||
export const POST = withUserAuth(createProjectTaskHandler);
|
||||
|
||||
50
src/app/api/project-tasks/users/route.js
Normal file
50
src/app/api/project-tasks/users/route.js
Normal file
@@ -0,0 +1,50 @@
|
||||
import {
|
||||
updateProjectTaskAssignment,
|
||||
getAllUsersForTaskAssignment,
|
||||
} from "@/lib/queries/tasks";
|
||||
import { NextResponse } from "next/server";
|
||||
import { withUserAuth, withReadAuth } from "@/lib/middleware/auth";
|
||||
|
||||
// GET: Get all users for task assignment
|
||||
async function getUsersForTaskAssignmentHandler(req) {
|
||||
try {
|
||||
const users = getAllUsersForTaskAssignment();
|
||||
return NextResponse.json(users);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch users" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// POST: Update task assignment
|
||||
async function updateTaskAssignmentHandler(req) {
|
||||
try {
|
||||
const { taskId, assignedToUserId } = await req.json();
|
||||
|
||||
if (!taskId) {
|
||||
return NextResponse.json(
|
||||
{ error: "taskId is required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const result = updateProjectTaskAssignment(taskId, assignedToUserId);
|
||||
|
||||
if (result.changes === 0) {
|
||||
return NextResponse.json({ error: "Task not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to update task assignment" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Protected routes
|
||||
export const GET = withReadAuth(getUsersForTaskAssignmentHandler);
|
||||
export const POST = withUserAuth(updateTaskAssignmentHandler);
|
||||
@@ -1,22 +1,103 @@
|
||||
// Force this API route to use Node.js runtime for database access
|
||||
export const runtime = "nodejs";
|
||||
|
||||
import {
|
||||
getProjectById,
|
||||
updateProject,
|
||||
deleteProject,
|
||||
} from "@/lib/queries/projects";
|
||||
import initializeDatabase from "@/lib/init-db";
|
||||
import { NextResponse } from "next/server";
|
||||
import { withReadAuth, withUserAuth } from "@/lib/middleware/auth";
|
||||
import {
|
||||
logApiActionSafe,
|
||||
AUDIT_ACTIONS,
|
||||
RESOURCE_TYPES,
|
||||
} from "@/lib/auditLogSafe.js";
|
||||
|
||||
// Make sure the DB is initialized before queries run
|
||||
initializeDatabase();
|
||||
|
||||
async function getProjectHandler(req, { params }) {
|
||||
const { id } = await params;
|
||||
const project = getProjectById(parseInt(id));
|
||||
|
||||
if (!project) {
|
||||
return NextResponse.json({ error: "Project not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Log project view
|
||||
await logApiActionSafe(
|
||||
req,
|
||||
AUDIT_ACTIONS.PROJECT_VIEW,
|
||||
RESOURCE_TYPES.PROJECT,
|
||||
id,
|
||||
req.auth, // Use req.auth instead of req.session
|
||||
{ project_name: project.project_name }
|
||||
);
|
||||
|
||||
export async function GET(_, { params }) {
|
||||
const project = getProjectById(params.id);
|
||||
return NextResponse.json(project);
|
||||
}
|
||||
|
||||
export async function PUT(req, { params }) {
|
||||
async function updateProjectHandler(req, { params }) {
|
||||
const { id } = await params;
|
||||
const data = await req.json();
|
||||
updateProject(params.id, data);
|
||||
|
||||
// Get user ID from authenticated request
|
||||
const userId = req.user?.id;
|
||||
|
||||
// Get original project data for audit log
|
||||
const originalProject = getProjectById(parseInt(id));
|
||||
|
||||
updateProject(parseInt(id), data, userId);
|
||||
|
||||
// Get updated project
|
||||
const updatedProject = getProjectById(parseInt(id));
|
||||
|
||||
// Log project update
|
||||
await logApiActionSafe(
|
||||
req,
|
||||
AUDIT_ACTIONS.PROJECT_UPDATE,
|
||||
RESOURCE_TYPES.PROJECT,
|
||||
id,
|
||||
req.auth, // Use req.auth instead of req.session
|
||||
{
|
||||
originalData: originalProject,
|
||||
updatedData: data,
|
||||
changedFields: Object.keys(data),
|
||||
}
|
||||
);
|
||||
|
||||
return NextResponse.json(updatedProject);
|
||||
}
|
||||
|
||||
async function deleteProjectHandler(req, { params }) {
|
||||
const { id } = await params;
|
||||
|
||||
// Get project data before deletion for audit log
|
||||
const project = getProjectById(parseInt(id));
|
||||
|
||||
deleteProject(parseInt(id));
|
||||
|
||||
// Log project deletion
|
||||
await logApiActionSafe(
|
||||
req,
|
||||
AUDIT_ACTIONS.PROJECT_DELETE,
|
||||
RESOURCE_TYPES.PROJECT,
|
||||
id,
|
||||
req.auth, // Use req.auth instead of req.session
|
||||
{
|
||||
deletedProject: {
|
||||
project_name: project?.project_name,
|
||||
project_number: project?.project_number,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
export async function DELETE(_, { params }) {
|
||||
deleteProject(params.id);
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
// Protected routes - require authentication
|
||||
export const GET = withReadAuth(getProjectHandler);
|
||||
export const PUT = withUserAuth(updateProjectHandler);
|
||||
export const DELETE = withUserAuth(deleteProjectHandler);
|
||||
|
||||
@@ -1,20 +1,90 @@
|
||||
import { getAllProjects, createProject } from "@/lib/queries/projects";
|
||||
// Force this API route to use Node.js runtime for database access
|
||||
export const runtime = "nodejs";
|
||||
|
||||
import {
|
||||
getAllProjects,
|
||||
createProject,
|
||||
getAllUsersForAssignment,
|
||||
} from "@/lib/queries/projects";
|
||||
import initializeDatabase from "@/lib/init-db";
|
||||
import { NextResponse } from "next/server";
|
||||
import { withReadAuth, withUserAuth } from "@/lib/middleware/auth";
|
||||
import {
|
||||
logApiActionSafe,
|
||||
AUDIT_ACTIONS,
|
||||
RESOURCE_TYPES,
|
||||
} from "@/lib/auditLogSafe.js";
|
||||
|
||||
// Make sure the DB is initialized before queries run
|
||||
initializeDatabase();
|
||||
|
||||
export async function GET(req) {
|
||||
async function getProjectsHandler(req) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const contractId = searchParams.get("contract_id");
|
||||
const assignedTo = searchParams.get("assigned_to");
|
||||
const createdBy = searchParams.get("created_by");
|
||||
|
||||
let projects;
|
||||
|
||||
if (assignedTo) {
|
||||
const { getProjectsByAssignedUser } = await import(
|
||||
"@/lib/queries/projects"
|
||||
);
|
||||
projects = getProjectsByAssignedUser(assignedTo);
|
||||
} else if (createdBy) {
|
||||
const { getProjectsByCreator } = await import("@/lib/queries/projects");
|
||||
projects = getProjectsByCreator(createdBy);
|
||||
} else {
|
||||
projects = getAllProjects(contractId);
|
||||
}
|
||||
|
||||
// Log project list access
|
||||
await logApiActionSafe(
|
||||
req,
|
||||
AUDIT_ACTIONS.PROJECT_VIEW,
|
||||
RESOURCE_TYPES.PROJECT,
|
||||
null, // No specific project ID for list view
|
||||
req.auth, // Use req.auth instead of req.session
|
||||
{
|
||||
filters: { contractId, assignedTo, createdBy },
|
||||
resultCount: projects.length,
|
||||
}
|
||||
);
|
||||
|
||||
const projects = getAllProjects(contractId);
|
||||
return NextResponse.json(projects);
|
||||
}
|
||||
|
||||
export async function POST(req) {
|
||||
async function createProjectHandler(req) {
|
||||
const data = await req.json();
|
||||
createProject(data);
|
||||
return NextResponse.json({ success: true });
|
||||
|
||||
// Get user ID from authenticated request
|
||||
const userId = req.user?.id;
|
||||
|
||||
const result = createProject(data, userId);
|
||||
const projectId = result.lastInsertRowid;
|
||||
|
||||
// Log project creation
|
||||
await logApiActionSafe(
|
||||
req,
|
||||
AUDIT_ACTIONS.PROJECT_CREATE,
|
||||
RESOURCE_TYPES.PROJECT,
|
||||
projectId.toString(),
|
||||
req.auth, // Use req.auth instead of req.session
|
||||
{
|
||||
projectData: {
|
||||
project_name: data.project_name,
|
||||
project_number: data.project_number,
|
||||
contract_id: data.contract_id,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
projectId: projectId,
|
||||
});
|
||||
}
|
||||
|
||||
// Protected routes - require authentication
|
||||
export const GET = withReadAuth(getProjectsHandler);
|
||||
export const POST = withUserAuth(createProjectHandler);
|
||||
|
||||
33
src/app/api/projects/users/route.js
Normal file
33
src/app/api/projects/users/route.js
Normal file
@@ -0,0 +1,33 @@
|
||||
import {
|
||||
getAllUsersForAssignment,
|
||||
updateProjectAssignment,
|
||||
} from "@/lib/queries/projects";
|
||||
import initializeDatabase from "@/lib/init-db";
|
||||
import { NextResponse } from "next/server";
|
||||
import { withUserAuth } from "@/lib/middleware/auth";
|
||||
|
||||
// Make sure the DB is initialized before queries run
|
||||
initializeDatabase();
|
||||
|
||||
async function getUsersHandler(req) {
|
||||
const users = getAllUsersForAssignment();
|
||||
return NextResponse.json(users);
|
||||
}
|
||||
|
||||
async function updateAssignmentHandler(req) {
|
||||
const { projectId, assignedToUserId } = await req.json();
|
||||
|
||||
if (!projectId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Project ID is required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
updateProjectAssignment(projectId, assignedToUserId);
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
// Protected routes - require authentication
|
||||
export const GET = withUserAuth(getUsersHandler);
|
||||
export const POST = withUserAuth(updateAssignmentHandler);
|
||||
@@ -4,9 +4,10 @@ import {
|
||||
deleteNote,
|
||||
} from "@/lib/queries/notes";
|
||||
import { NextResponse } from "next/server";
|
||||
import { withReadAuth, withUserAuth } from "@/lib/middleware/auth";
|
||||
|
||||
// GET: Get notes for a specific task
|
||||
export async function GET(req) {
|
||||
async function getTaskNotesHandler(req) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const taskId = searchParams.get("task_id");
|
||||
|
||||
@@ -26,7 +27,7 @@ export async function GET(req) {
|
||||
}
|
||||
|
||||
// POST: Add a note to a task
|
||||
export async function POST(req) {
|
||||
async function addTaskNoteHandler(req) {
|
||||
try {
|
||||
const { task_id, note, is_system } = await req.json();
|
||||
|
||||
@@ -37,7 +38,7 @@ export async function POST(req) {
|
||||
);
|
||||
}
|
||||
|
||||
addNoteToTask(task_id, note, is_system);
|
||||
addNoteToTask(task_id, note, is_system, req.user?.id || null);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Error adding task note:", error);
|
||||
@@ -49,7 +50,7 @@ export async function POST(req) {
|
||||
}
|
||||
|
||||
// DELETE: Delete a note
|
||||
export async function DELETE(req) {
|
||||
async function deleteTaskNoteHandler(req) {
|
||||
try {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const noteId = searchParams.get("note_id");
|
||||
@@ -71,3 +72,8 @@ export async function DELETE(req) {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Protected routes - require authentication
|
||||
export const GET = withReadAuth(getTaskNotesHandler);
|
||||
export const POST = withUserAuth(addTaskNoteHandler);
|
||||
export const DELETE = withUserAuth(deleteTaskNoteHandler);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import db from "@/lib/db";
|
||||
import { NextResponse } from "next/server";
|
||||
import { withReadAuth, withUserAuth } from "@/lib/middleware/auth";
|
||||
|
||||
// GET: Get a specific task template
|
||||
export async function GET(req, { params }) {
|
||||
async function getTaskHandler(req, { params }) {
|
||||
try {
|
||||
const template = db
|
||||
.prepare("SELECT * FROM tasks WHERE task_id = ? AND is_standard = 1")
|
||||
@@ -25,7 +26,7 @@ export async function GET(req, { params }) {
|
||||
}
|
||||
|
||||
// PUT: Update a task template
|
||||
export async function PUT(req, { params }) {
|
||||
async function updateTaskHandler(req, { params }) {
|
||||
try {
|
||||
const { name, max_wait_days, description } = await req.json();
|
||||
|
||||
@@ -58,7 +59,7 @@ export async function PUT(req, { params }) {
|
||||
}
|
||||
|
||||
// DELETE: Delete a task template
|
||||
export async function DELETE(req, { params }) {
|
||||
async function deleteTaskHandler(req, { params }) {
|
||||
try {
|
||||
const result = db
|
||||
.prepare("DELETE FROM tasks WHERE task_id = ? AND is_standard = 1")
|
||||
@@ -79,3 +80,8 @@ export async function DELETE(req, { params }) {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Protected routes - require authentication
|
||||
export const GET = withReadAuth(getTaskHandler);
|
||||
export const PUT = withUserAuth(updateTaskHandler);
|
||||
export const DELETE = withUserAuth(deleteTaskHandler);
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import db from "@/lib/db";
|
||||
import { NextResponse } from "next/server";
|
||||
import { withUserAuth, withReadAuth } from "@/lib/middleware/auth";
|
||||
import { getAllTaskTemplates } from "@/lib/queries/tasks";
|
||||
|
||||
// POST: create new template
|
||||
export async function POST(req) {
|
||||
async function createTaskHandler(req) {
|
||||
const { name, max_wait_days, description } = await req.json();
|
||||
|
||||
if (!name) {
|
||||
@@ -18,3 +20,13 @@ export async function POST(req) {
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
// GET: Get all task templates
|
||||
async function getTasksHandler(req) {
|
||||
const templates = getAllTaskTemplates();
|
||||
return NextResponse.json(templates);
|
||||
}
|
||||
|
||||
// Protected routes - require authentication
|
||||
export const GET = withReadAuth(getTasksHandler);
|
||||
export const POST = withUserAuth(createTaskHandler);
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { getAllTaskTemplates } from "@/lib/queries/tasks";
|
||||
import { NextResponse } from "next/server";
|
||||
import { withReadAuth } from "@/lib/middleware/auth";
|
||||
|
||||
// GET: Get all task templates
|
||||
export async function GET() {
|
||||
async function getTaskTemplatesHandler() {
|
||||
const templates = getAllTaskTemplates();
|
||||
return NextResponse.json(templates);
|
||||
}
|
||||
|
||||
// Protected routes - require authentication
|
||||
export const GET = withReadAuth(getTaskTemplatesHandler);
|
||||
|
||||
65
src/app/auth/error/page.js
Normal file
65
src/app/auth/error/page.js
Normal file
@@ -0,0 +1,65 @@
|
||||
'use client'
|
||||
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { Suspense } from 'react'
|
||||
|
||||
function AuthErrorContent() {
|
||||
const searchParams = useSearchParams()
|
||||
const error = searchParams.get('error')
|
||||
|
||||
const getErrorMessage = (error) => {
|
||||
switch (error) {
|
||||
case 'CredentialsSignin':
|
||||
return 'Invalid email or password. Please check your credentials and try again.'
|
||||
case 'AccessDenied':
|
||||
return 'Access denied. You do not have permission to sign in.'
|
||||
case 'Verification':
|
||||
return 'The verification token has expired or has already been used.'
|
||||
default:
|
||||
return 'An unexpected error occurred during authentication. Please try again.'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div className="text-center">
|
||||
<h2 className="mt-6 text-3xl font-extrabold text-gray-900">
|
||||
Authentication Error
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
{getErrorMessage(error)}
|
||||
</p>
|
||||
{error && (
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Error code: {error}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-6">
|
||||
<a
|
||||
href="/auth/signin"
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
Back to Sign In
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AuthError() {
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto mb-4"></div>
|
||||
<p className="text-gray-600">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
}>
|
||||
<AuthErrorContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
142
src/app/auth/signin/page.js
Normal file
142
src/app/auth/signin/page.js
Normal file
@@ -0,0 +1,142 @@
|
||||
"use client"
|
||||
|
||||
import { useState, Suspense } from "react"
|
||||
import { signIn, getSession } from "next-auth/react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
|
||||
function SignInContent() {
|
||||
const [email, setEmail] = useState("")
|
||||
const [password, setPassword] = useState("")
|
||||
const [error, setError] = useState("")
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const callbackUrl = searchParams.get("callbackUrl") || "/"
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
setError("")
|
||||
|
||||
try {
|
||||
const result = await signIn("credentials", {
|
||||
email,
|
||||
password,
|
||||
redirect: false,
|
||||
})
|
||||
|
||||
if (result?.error) {
|
||||
setError("Invalid email or password")
|
||||
} else {
|
||||
// Successful login
|
||||
router.push(callbackUrl)
|
||||
router.refresh()
|
||||
}
|
||||
} catch (error) {
|
||||
setError("An error occurred. Please try again.")
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div>
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||
Sign in to your account
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-gray-600">
|
||||
Access the Project Management Panel
|
||||
</p>
|
||||
</div>
|
||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded relative">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-md shadow-sm -space-y-px">
|
||||
<div>
|
||||
<label htmlFor="email" className="sr-only">
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Email address"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="password" className="sr-only">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className="flex items-center">
|
||||
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Signing in...
|
||||
</span>
|
||||
) : (
|
||||
"Sign in"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="text-sm text-gray-600 bg-blue-50 p-3 rounded">
|
||||
<p className="font-medium">Default Admin Account:</p>
|
||||
<p>Email: admin@localhost</p>
|
||||
<p>Password: admin123456</p>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SignIn() {
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto mb-4"></div>
|
||||
<p className="text-gray-600">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
}>
|
||||
<SignInContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -1,347 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from 'react';
|
||||
import ComprehensivePolishMap from '../../components/ui/ComprehensivePolishMap';
|
||||
|
||||
export default function ComprehensivePolishMapPage() {
|
||||
const [selectedLocation, setSelectedLocation] = useState('krakow');
|
||||
|
||||
// Different locations to test the layers
|
||||
const locations = {
|
||||
krakow: {
|
||||
center: [50.0647, 19.9450],
|
||||
zoom: 14,
|
||||
name: "Kraków",
|
||||
description: "Historic city center with good cadastral data coverage"
|
||||
},
|
||||
warsaw: {
|
||||
center: [52.2297, 21.0122],
|
||||
zoom: 14,
|
||||
name: "Warszawa",
|
||||
description: "Capital city with extensive planning data"
|
||||
},
|
||||
gdansk: {
|
||||
center: [54.3520, 18.6466],
|
||||
zoom: 14,
|
||||
name: "Gdańsk",
|
||||
description: "Port city with detailed property boundaries"
|
||||
},
|
||||
wroclaw: {
|
||||
center: [51.1079, 17.0385],
|
||||
zoom: 14,
|
||||
name: "Wrocław",
|
||||
description: "University city with good orthophoto coverage"
|
||||
},
|
||||
poznan: {
|
||||
center: [52.4064, 16.9252],
|
||||
zoom: 14,
|
||||
name: "Poznań",
|
||||
description: "Industrial center with road network data"
|
||||
}
|
||||
};
|
||||
|
||||
const currentLocation = locations[selectedLocation];
|
||||
|
||||
// Test markers for selected location
|
||||
const testMarkers = [
|
||||
{
|
||||
position: currentLocation.center,
|
||||
popup: `${currentLocation.name} - ${currentLocation.description}`
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-4xl font-bold text-gray-800 mb-6">
|
||||
🇵🇱 Comprehensive Polish Geospatial Data Platform
|
||||
</h1>
|
||||
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-6 mb-6">
|
||||
<h2 className="text-xl font-semibold text-green-800 mb-3">
|
||||
All Polish Layers Implementation Complete! 🎉
|
||||
</h2>
|
||||
<p className="text-green-700 mb-4">
|
||||
This comprehensive map includes all layers from your OpenLayers implementation,
|
||||
converted to work seamlessly with your Leaflet-based React/Next.js project.
|
||||
</p>
|
||||
<div className="grid md:grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<strong className="text-green-800">Base Layers:</strong>
|
||||
<ul className="mt-1 text-green-700">
|
||||
<li>• Polish Orthophoto (Standard & High Resolution)</li>
|
||||
<li>• OpenStreetMap, Google Maps, Esri Satellite</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<strong className="text-green-800">Overlay Layers:</strong>
|
||||
<ul className="mt-1 text-green-700">
|
||||
<li>• Cadastral Data, Spatial Planning</li>
|
||||
<li>• LP-Portal Roads, Street Names, Parcels, Surveys</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Location Selector */}
|
||||
<div className="bg-white rounded-lg shadow-lg p-4 mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-800 mb-3">
|
||||
🎯 Select Test Location:
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
|
||||
{Object.entries(locations).map(([key, location]) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setSelectedLocation(key)}
|
||||
className={`px-3 py-2 rounded-lg text-sm transition-colors ${
|
||||
selectedLocation === key
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
||||
}`}
|
||||
>
|
||||
{location.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mt-2">
|
||||
<strong>Current:</strong> {currentLocation.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Map Container */}
|
||||
<div className="bg-white rounded-lg shadow-lg overflow-hidden">
|
||||
<div className="p-4 bg-blue-600 text-white">
|
||||
<h2 className="text-xl font-semibold">
|
||||
Interactive Map: {currentLocation.name}
|
||||
</h2>
|
||||
<p className="text-blue-100 mt-2">
|
||||
Use the layer control (top-right) to toggle between base layers and enable overlay layers.
|
||||
Combine orthophoto with cadastral data for detailed property analysis.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="h-96 md:h-[700px]">
|
||||
<ComprehensivePolishMap
|
||||
key={selectedLocation} // Force re-render when location changes
|
||||
center={currentLocation.center}
|
||||
zoom={currentLocation.zoom}
|
||||
markers={testMarkers}
|
||||
showLayerControl={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Layer Information */}
|
||||
<div className="mt-8 grid md:grid-cols-2 gap-6">
|
||||
{/* Base Layers */}
|
||||
<div className="bg-white rounded-lg shadow-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-800 mb-4 flex items-center">
|
||||
🗺️ Base Layers
|
||||
</h3>
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="flex items-start">
|
||||
<span className="w-4 h-4 bg-green-500 rounded-full mr-3 mt-0.5 flex-shrink-0"></span>
|
||||
<div>
|
||||
<strong>Polish Orthophoto (Standard)</strong>
|
||||
<p className="text-gray-600 mt-1">High-quality aerial imagery from Polish Geoportal</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
<span className="w-4 h-4 bg-emerald-500 rounded-full mr-3 mt-0.5 flex-shrink-0"></span>
|
||||
<div>
|
||||
<strong>Polish Orthophoto (High Resolution)</strong>
|
||||
<p className="text-gray-600 mt-1">Ultra-high resolution aerial imagery for detailed analysis</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
<span className="w-4 h-4 bg-blue-500 rounded-full mr-3 mt-0.5 flex-shrink-0"></span>
|
||||
<div>
|
||||
<strong>OpenStreetMap</strong>
|
||||
<p className="text-gray-600 mt-1">Community-driven map data</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
<span className="w-4 h-4 bg-red-500 rounded-full mr-3 mt-0.5 flex-shrink-0"></span>
|
||||
<div>
|
||||
<strong>Google Maps</strong>
|
||||
<p className="text-gray-600 mt-1">Satellite imagery and road overlay</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Overlay Layers */}
|
||||
<div className="bg-white rounded-lg shadow-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-800 mb-4 flex items-center">
|
||||
📊 Overlay Layers
|
||||
</h3> <div className="space-y-3 text-sm">
|
||||
<div className="flex items-start">
|
||||
<span className="w-4 h-4 bg-orange-500 rounded-full mr-3 mt-0.5 flex-shrink-0"></span>
|
||||
<div>
|
||||
<strong>📋 Polish Cadastral Data</strong>
|
||||
<p className="text-gray-600 mt-1">Property boundaries, parcels, and building outlines</p>
|
||||
<p className="text-xs text-gray-500">Opacity: 80% - Semi-transparent overlay</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
<span className="w-4 h-4 bg-purple-500 rounded-full mr-3 mt-0.5 flex-shrink-0"></span>
|
||||
<div>
|
||||
<strong>🏗️ Polish Spatial Planning</strong>
|
||||
<p className="text-gray-600 mt-1">Zoning data and urban planning information</p>
|
||||
<p className="text-xs text-gray-500">Opacity: 70% - Semi-transparent overlay</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
<span className="w-4 h-4 bg-teal-500 rounded-full mr-3 mt-0.5 flex-shrink-0"></span>
|
||||
<div>
|
||||
<strong>🛣️ LP-Portal Roads</strong>
|
||||
<p className="text-gray-600 mt-1">Detailed road network data</p>
|
||||
<p className="text-xs text-gray-500">Opacity: 90% - Mostly opaque for visibility</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
<span className="w-4 h-4 bg-indigo-500 rounded-full mr-3 mt-0.5 flex-shrink-0"></span>
|
||||
<div>
|
||||
<strong>🏷️ LP-Portal Street Names</strong>
|
||||
<p className="text-gray-600 mt-1">Street names and road descriptions</p>
|
||||
<p className="text-xs text-gray-500">Opacity: 100% - Fully opaque for readability</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
<span className="w-4 h-4 bg-pink-500 rounded-full mr-3 mt-0.5 flex-shrink-0"></span>
|
||||
<div>
|
||||
<strong>📐 LP-Portal Parcels & Surveys</strong>
|
||||
<p className="text-gray-600 mt-1">Property parcels and survey markers</p>
|
||||
<p className="text-xs text-gray-500">Opacity: 60-80% - Variable transparency</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Transparency Information */}
|
||||
<div className="mt-8 bg-green-50 border border-green-200 rounded-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-green-800 mb-4">
|
||||
🎨 Layer Transparency Handling
|
||||
</h3>
|
||||
<div className="grid md:grid-cols-2 gap-6 text-sm">
|
||||
<div>
|
||||
<h4 className="font-semibold text-green-700 mb-3">Base Layers (Opaque):</h4>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span>Polish Orthophoto</span>
|
||||
<span className="bg-green-200 px-2 py-1 rounded text-xs">100% Opaque</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Google Satellite/Roads</span>
|
||||
<span className="bg-green-200 px-2 py-1 rounded text-xs">100% Opaque</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-semibold text-green-700 mb-3">Overlay Layers (Transparent):</h4>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span>📋 Cadastral Data</span>
|
||||
<span className="bg-yellow-200 px-2 py-1 rounded text-xs">80% Opacity</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>🏗️ Spatial Planning</span>
|
||||
<span className="bg-yellow-200 px-2 py-1 rounded text-xs">70% Opacity</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>🛣️ Roads</span>
|
||||
<span className="bg-blue-200 px-2 py-1 rounded text-xs">90% Opacity</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>🏷️ Street Names</span>
|
||||
<span className="bg-green-200 px-2 py-1 rounded text-xs">100% Opacity</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>📐 Parcels</span>
|
||||
<span className="bg-orange-200 px-2 py-1 rounded text-xs">60% Opacity</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>📍 Survey Markers</span>
|
||||
<span className="bg-yellow-200 px-2 py-1 rounded text-xs">80% Opacity</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 p-3 bg-green-100 rounded">
|
||||
<p className="text-green-800 text-sm">
|
||||
<strong>Smart Transparency:</strong> Each overlay layer has been optimized with appropriate transparency levels.
|
||||
Property boundaries are semi-transparent (60-80%) so you can see the underlying imagery,
|
||||
while text labels are fully opaque (100%) for maximum readability.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Usage Guide */}
|
||||
<div className="mt-8 bg-blue-50 border border-blue-200 rounded-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-blue-800 mb-4">
|
||||
📋 How to Use This Comprehensive Map
|
||||
</h3>
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h4 className="font-semibold text-blue-700 mb-2">Basic Navigation:</h4>
|
||||
<ul className="text-blue-600 space-y-1 text-sm">
|
||||
<li>• Use mouse wheel to zoom in/out</li>
|
||||
<li>• Click and drag to pan around</li>
|
||||
<li>• Use layer control (top-right) to switch layers</li>
|
||||
<li>• Select different Polish cities above to test</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-blue-700 mb-2">Advanced Features:</h4>
|
||||
<ul className="text-blue-600 space-y-1 text-sm">
|
||||
<li>• Combine orthophoto with cadastral overlay</li>
|
||||
<li>• Enable multiple overlays simultaneously</li>
|
||||
<li>• Use high-resolution orthophoto for detail work</li>
|
||||
<li>• Compare with Google/OSM base layers</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Technical Implementation */}
|
||||
<div className="mt-8 bg-gray-50 border border-gray-200 rounded-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-800 mb-4">
|
||||
⚙️ Technical Implementation Details
|
||||
</h3>
|
||||
<div className="grid md:grid-cols-3 gap-6 text-sm">
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-700 mb-2">WMTS Integration:</h4>
|
||||
<ul className="text-gray-600 space-y-1">
|
||||
<li>• Proper KVP URL construction</li>
|
||||
<li>• EPSG:3857 coordinate system</li>
|
||||
<li>• Standard and high-res orthophoto</li>
|
||||
<li>• Multiple format support (JPEG/PNG)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-700 mb-2">WMS Overlays:</h4>
|
||||
<ul className="text-gray-600 space-y-1">
|
||||
<li>• Polish government services</li>
|
||||
<li>• LP-Portal municipal data</li>
|
||||
<li>• Transparent overlay support</li>
|
||||
<li>• Multiple layer combinations</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-700 mb-2">React/Leaflet:</h4>
|
||||
<ul className="text-gray-600 space-y-1">
|
||||
<li>• React-Leaflet component integration</li>
|
||||
<li>• Dynamic layer switching</li>
|
||||
<li>• Responsive design</li>
|
||||
<li>• Performance optimized</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import DebugPolishOrthophotoMap from '../../components/ui/DebugPolishOrthophotoMap';
|
||||
|
||||
export default function DebugPolishOrthophotoPage() {
|
||||
// Test marker in Poland
|
||||
const testMarkers = [
|
||||
{
|
||||
position: [50.0647, 19.9450], // Krakow
|
||||
popup: "Kraków - Test Location"
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold text-gray-800 mb-6">
|
||||
Debug Polish Geoportal Orthophoto
|
||||
</h1>
|
||||
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
|
||||
<h2 className="text-lg font-semibold text-red-800 mb-2">
|
||||
Debug Mode Active
|
||||
</h2>
|
||||
<p className="text-red-700">
|
||||
This page tests multiple URL formats for Polish Geoportal orthophoto tiles.
|
||||
Check the browser console and the debug panel on the map for network request information.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-lg overflow-hidden">
|
||||
<div className="p-4 bg-blue-600 text-white">
|
||||
<h2 className="text-xl font-semibold">Debug Map with Multiple Orthophoto Options</h2>
|
||||
<p className="text-blue-100 mt-2">
|
||||
Try switching between different Polish orthophoto options using the layer control.
|
||||
Google layers are included as working references.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="h-96 md:h-[600px]">
|
||||
<DebugPolishOrthophotoMap
|
||||
center={[50.0647, 19.9450]} // Centered on Krakow
|
||||
zoom={12}
|
||||
markers={testMarkers}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 bg-white rounded-lg shadow-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-800 mb-4">
|
||||
URL Formats Being Tested:
|
||||
</h3>
|
||||
<div className="space-y-4 text-sm"> <div className="bg-gray-50 p-3 rounded">
|
||||
<strong>Option 1 (WMTS KVP EPSG:3857):</strong>
|
||||
<code className="block mt-1 text-xs">
|
||||
?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=ORTO&STYLE=default&TILEMATRIXSET=EPSG:3857&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&FORMAT=image/jpeg
|
||||
</code>
|
||||
<span className="text-gray-600">Standard Web Mercator projection</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 p-3 rounded">
|
||||
<strong>Option 2 (WMTS KVP EPSG:2180):</strong>
|
||||
<code className="block mt-1 text-xs">
|
||||
?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=ORTO&STYLE=default&TILEMATRIXSET=EPSG:2180&TILEMATRIX=EPSG:2180:{z}&TILEROW={y}&TILECOL={x}&FORMAT=image/jpeg
|
||||
</code>
|
||||
<span className="text-gray-600">Polish coordinate system</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 p-3 rounded">
|
||||
<strong>Option 3 (Alternative TILEMATRIXSET):</strong>
|
||||
<code className="block mt-1 text-xs">
|
||||
?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=ORTO&STYLE=default&TILEMATRIXSET=GoogleMapsCompatible&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&FORMAT=image/jpeg
|
||||
</code>
|
||||
<span className="text-gray-600">Google Maps compatible matrix</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 p-3 rounded">
|
||||
<strong>Option 4 (PNG format):</strong>
|
||||
<code className="block mt-1 text-xs">
|
||||
?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=ORTO&STYLE=default&TILEMATRIXSET=EPSG:3857&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&FORMAT=image/png
|
||||
</code>
|
||||
<span className="text-gray-600">PNG format instead of JPEG</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 bg-yellow-50 border border-yellow-200 rounded-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-yellow-800 mb-2">
|
||||
Debug Instructions:
|
||||
</h3>
|
||||
<ol className="text-yellow-700 space-y-2">
|
||||
<li><strong>1.</strong> Open browser Developer Tools (F12) and go to Network tab</li>
|
||||
<li><strong>2.</strong> Switch between different Polish orthophoto options in the layer control</li>
|
||||
<li><strong>3.</strong> Look for requests to geoportal.gov.pl in the Network tab</li>
|
||||
<li><strong>4.</strong> Check the debug panel on the map for request/response info</li>
|
||||
<li><strong>5.</strong> Note which options return 200 OK vs 404/403 errors</li>
|
||||
<li><strong>6.</strong> Compare with working Google layers</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import Navigation from "@/components/ui/Navigation";
|
||||
import { AuthProvider } from "@/components/auth/AuthProvider";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
@@ -23,8 +24,10 @@ export default function RootLayout({ children }) {
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
<Navigation />
|
||||
<main>{children}</main>
|
||||
<AuthProvider>
|
||||
<Navigation />
|
||||
<main>{children}</main>
|
||||
</AuthProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import Link from "next/link";
|
||||
import { Card, CardHeader, CardContent } from "@/components/ui/Card";
|
||||
import Button from "@/components/ui/Button";
|
||||
@@ -24,6 +25,7 @@ import { formatDate } from "@/lib/utils";
|
||||
import TaskStatusChart from "@/components/ui/TaskStatusChart";
|
||||
|
||||
export default function Home() {
|
||||
const { data: session, status } = useSession();
|
||||
const [stats, setStats] = useState({
|
||||
totalProjects: 0,
|
||||
activeProjects: 0,
|
||||
@@ -47,6 +49,12 @@ export default function Home() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Only fetch data if user is authenticated
|
||||
if (!session) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchDashboardData = async () => {
|
||||
try {
|
||||
// Fetch all data concurrently
|
||||
@@ -210,7 +218,7 @@ export default function Home() {
|
||||
};
|
||||
|
||||
fetchDashboardData();
|
||||
}, []);
|
||||
}, [session]);
|
||||
|
||||
const getProjectStatusColor = (status) => {
|
||||
switch (status) {
|
||||
@@ -257,10 +265,38 @@ export default function Home() {
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// Show loading state while session is being fetched
|
||||
if (status === "loading") {
|
||||
return <LoadingState message="Loading authentication..." />;
|
||||
}
|
||||
|
||||
// Show sign-in prompt if not authenticated
|
||||
if (!session) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className="text-center py-12">
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-6">
|
||||
Welcome to Project Management Panel
|
||||
</h1>
|
||||
<p className="text-xl text-gray-600 mb-8">
|
||||
Please sign in to access the project management system.
|
||||
</p>
|
||||
<Link
|
||||
href="/auth/signin"
|
||||
className="inline-flex items-center px-6 py-3 border border-transparent text-base font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
Sign In
|
||||
</Link>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Dashboard"
|
||||
title={`Welcome back, ${session.user.name}!`}
|
||||
description="Overview of your projects, contracts, and tasks"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
@@ -1,17 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import ProjectForm from "@/components/ProjectForm";
|
||||
import PageContainer from "@/components/ui/PageContainer";
|
||||
import PageHeader from "@/components/ui/PageHeader";
|
||||
import Button from "@/components/ui/Button";
|
||||
import Link from "next/link";
|
||||
import { LoadingState } from "@/components/ui/States";
|
||||
|
||||
export default async function EditProjectPage({ params }) {
|
||||
const { id } = await params;
|
||||
const res = await fetch(`http://localhost:3000/api/projects/${id}`, {
|
||||
cache: "no-store",
|
||||
});
|
||||
const project = await res.json();
|
||||
export default function EditProjectPage() {
|
||||
const params = useParams();
|
||||
const id = params.id;
|
||||
const [project, setProject] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
if (!project) {
|
||||
useEffect(() => {
|
||||
const fetchProject = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/projects/${id}`);
|
||||
if (res.ok) {
|
||||
const projectData = await res.json();
|
||||
setProject(projectData);
|
||||
} else {
|
||||
setError("Project not found");
|
||||
}
|
||||
} catch (err) {
|
||||
setError("Failed to load project");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (id) {
|
||||
fetchProject();
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<LoadingState />
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !project) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className="text-center py-12">
|
||||
|
||||
@@ -13,12 +13,12 @@ import { formatDate } from "@/lib/utils";
|
||||
import PageContainer from "@/components/ui/PageContainer";
|
||||
import PageHeader from "@/components/ui/PageHeader";
|
||||
import ProjectStatusDropdown from "@/components/ProjectStatusDropdown";
|
||||
import ProjectMap from "@/components/ui/ProjectMap";
|
||||
import ClientProjectMap from "@/components/ui/ClientProjectMap";
|
||||
|
||||
export default async function ProjectViewPage({ params }) {
|
||||
const { id } = await params;
|
||||
const project = getProjectWithContract(id);
|
||||
const notes = getNotesForProject(id);
|
||||
const project = await getProjectWithContract(id);
|
||||
const notes = await getNotesForProject(id);
|
||||
|
||||
if (!project) {
|
||||
return (
|
||||
@@ -400,12 +400,20 @@ export default async function ProjectViewPage({ params }) {
|
||||
<div className="mb-8">
|
||||
{" "}
|
||||
<Card>
|
||||
<CardHeader> <div className="flex items-center justify-between">
|
||||
<CardHeader>
|
||||
{" "}
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold text-gray-900">
|
||||
Project Location
|
||||
</h2>
|
||||
{project.coordinates && (
|
||||
<Link href={`/projects/map?lat=${project.coordinates.split(',')[0].trim()}&lng=${project.coordinates.split(',')[1].trim()}&zoom=16`}>
|
||||
<Link
|
||||
href={`/projects/map?lat=${project.coordinates
|
||||
.split(",")[0]
|
||||
.trim()}&lng=${project.coordinates
|
||||
.split(",")[1]
|
||||
.trim()}&zoom=16`}
|
||||
>
|
||||
<Button variant="outline" size="sm">
|
||||
<svg
|
||||
className="w-4 h-4 mr-2"
|
||||
@@ -427,7 +435,7 @@ export default async function ProjectViewPage({ params }) {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ProjectMap
|
||||
<ClientProjectMap
|
||||
coordinates={project.coordinates}
|
||||
projectName={project.project_name}
|
||||
projectStatus={project.project_status}
|
||||
@@ -481,9 +489,16 @@ export default async function ProjectViewPage({ params }) {
|
||||
className="border border-gray-200 p-4 rounded-lg bg-gray-50 hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-gray-500">
|
||||
{n.note_date}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-500">
|
||||
{n.note_date}
|
||||
</span>
|
||||
{n.created_by_name && (
|
||||
<span className="px-2 py-1 text-xs bg-blue-100 text-blue-700 rounded-full font-medium">
|
||||
{n.created_by_name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-900 leading-relaxed">{n.note}</p>
|
||||
</div>
|
||||
|
||||
928
src/app/projects/map/page-old.js
Normal file
928
src/app/projects/map/page-old.js
Normal file
@@ -0,0 +1,928 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import Button from "@/components/ui/Button";
|
||||
import { mapLayers } from "@/components/ui/mapLayers";
|
||||
|
||||
// Dynamically import the map component to avoid SSR issues
|
||||
const DynamicMap = dynamic(() => import("@/components/ui/LeafletMap"), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="w-full h-96 bg-gray-100 animate-pulse rounded-lg flex items-center justify-center">
|
||||
<span className="text-gray-500">Loading map...</span>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
|
||||
export default function ProjectsMapPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const [projects, setProjects] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [mapCenter, setMapCenter] = useState([50.0614, 19.9366]); // Default to Krakow, Poland
|
||||
const [mapZoom, setMapZoom] = useState(10); // Default zoom level
|
||||
const [statusFilters, setStatusFilters] = useState({
|
||||
registered: true,
|
||||
in_progress_design: true,
|
||||
in_progress_construction: true,
|
||||
fulfilled: true,
|
||||
});
|
||||
const [activeBaseLayer, setActiveBaseLayer] = useState("OpenStreetMap");
|
||||
const [activeOverlays, setActiveOverlays] = useState([]);
|
||||
const [showLayerPanel, setShowLayerPanel] = useState(true);
|
||||
const [currentTool, setCurrentTool] = useState("move"); // Current map tool
|
||||
|
||||
// Status configuration with colors and labels
|
||||
const statusConfig = {
|
||||
registered: {
|
||||
color: "#6B7280",
|
||||
label: "Registered",
|
||||
shortLabel: "Zarejestr.",
|
||||
},
|
||||
in_progress_design: {
|
||||
color: "#3B82F6",
|
||||
label: "In Progress (Design)",
|
||||
shortLabel: "W real. (P)",
|
||||
},
|
||||
in_progress_construction: {
|
||||
color: "#F59E0B",
|
||||
label: "In Progress (Construction)",
|
||||
shortLabel: "W real. (R)",
|
||||
},
|
||||
fulfilled: {
|
||||
color: "#10B981",
|
||||
label: "Completed",
|
||||
shortLabel: "Zakończony",
|
||||
},
|
||||
};
|
||||
|
||||
// Toggle all status filters
|
||||
const toggleAllFilters = () => {
|
||||
const allActive = Object.values(statusFilters).every((value) => value);
|
||||
const newState = allActive
|
||||
? Object.keys(statusFilters).reduce(
|
||||
(acc, key) => ({ ...acc, [key]: false }),
|
||||
{}
|
||||
)
|
||||
: Object.keys(statusFilters).reduce(
|
||||
(acc, key) => ({ ...acc, [key]: true }),
|
||||
{}
|
||||
);
|
||||
setStatusFilters(newState);
|
||||
};
|
||||
|
||||
// Toggle status filter
|
||||
const toggleStatusFilter = (status) => {
|
||||
setStatusFilters((prev) => ({
|
||||
...prev,
|
||||
[status]: !prev[status],
|
||||
}));
|
||||
};
|
||||
|
||||
// Layer control functions
|
||||
const handleBaseLayerChange = (layerName) => {
|
||||
setActiveBaseLayer(layerName);
|
||||
};
|
||||
|
||||
const toggleOverlay = (layerName) => {
|
||||
setActiveOverlays((prev) => {
|
||||
if (prev.includes(layerName)) {
|
||||
return prev.filter((name) => name !== layerName);
|
||||
} else {
|
||||
return [...prev, layerName];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const toggleLayerPanel = () => {
|
||||
setShowLayerPanel(!showLayerPanel);
|
||||
};
|
||||
|
||||
// Update URL with current map state (debounced to avoid too many updates)
|
||||
const updateURL = (center, zoom) => {
|
||||
const params = new URLSearchParams();
|
||||
params.set("lat", center[0].toFixed(6));
|
||||
params.set("lng", center[1].toFixed(6));
|
||||
params.set("zoom", zoom.toString());
|
||||
|
||||
// Use replace to avoid cluttering browser history
|
||||
router.replace(`/projects/map?${params.toString()}`, { scroll: false });
|
||||
};
|
||||
|
||||
// Handle map view changes with debouncing
|
||||
const handleMapViewChange = (center, zoom) => {
|
||||
setMapCenter(center);
|
||||
setMapZoom(zoom);
|
||||
|
||||
// Debounce URL updates to avoid too many history entries
|
||||
clearTimeout(window.mapUpdateTimeout);
|
||||
window.mapUpdateTimeout = setTimeout(() => {
|
||||
updateURL(center, zoom);
|
||||
}, 500); // Wait 500ms after the last move to update URL
|
||||
};
|
||||
|
||||
// Hide navigation and ensure full-screen layout
|
||||
useEffect(() => {
|
||||
// Check for URL parameters for coordinates and zoom
|
||||
const lat = searchParams.get("lat");
|
||||
const lng = searchParams.get("lng");
|
||||
const zoom = searchParams.get("zoom");
|
||||
|
||||
if (lat && lng) {
|
||||
const latitude = parseFloat(lat);
|
||||
const longitude = parseFloat(lng);
|
||||
if (!isNaN(latitude) && !isNaN(longitude)) {
|
||||
setMapCenter([latitude, longitude]);
|
||||
}
|
||||
}
|
||||
|
||||
if (zoom) {
|
||||
const zoomLevel = parseInt(zoom);
|
||||
if (!isNaN(zoomLevel) && zoomLevel >= 1 && zoomLevel <= 20) {
|
||||
setMapZoom(zoomLevel);
|
||||
}
|
||||
}
|
||||
|
||||
// Hide navigation bar for full-screen experience
|
||||
const nav = document.querySelector("nav");
|
||||
if (nav) {
|
||||
nav.style.display = "none";
|
||||
}
|
||||
|
||||
// Prevent scrolling on body
|
||||
document.body.style.overflow = "hidden";
|
||||
document.documentElement.style.overflow = "hidden";
|
||||
|
||||
// Cleanup when leaving page
|
||||
return () => {
|
||||
if (nav) {
|
||||
nav.style.display = "";
|
||||
}
|
||||
document.body.style.overflow = "";
|
||||
document.documentElement.style.overflow = "";
|
||||
|
||||
// Clear any pending URL updates
|
||||
if (window.mapUpdateTimeout) {
|
||||
clearTimeout(window.mapUpdateTimeout);
|
||||
}
|
||||
};
|
||||
}, [searchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/projects")
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
setProjects(data);
|
||||
|
||||
// Only calculate center based on projects if no URL parameters are provided
|
||||
const lat = searchParams.get("lat");
|
||||
const lng = searchParams.get("lng");
|
||||
|
||||
if (!lat || !lng) {
|
||||
// Calculate center based on projects with coordinates
|
||||
const projectsWithCoords = data.filter((p) => p.coordinates);
|
||||
if (projectsWithCoords.length > 0) {
|
||||
const avgLat =
|
||||
projectsWithCoords.reduce((sum, p) => {
|
||||
const [lat] = p.coordinates
|
||||
.split(",")
|
||||
.map((coord) => parseFloat(coord.trim()));
|
||||
return sum + lat;
|
||||
}, 0) / projectsWithCoords.length;
|
||||
|
||||
const avgLng =
|
||||
projectsWithCoords.reduce((sum, p) => {
|
||||
const [, lng] = p.coordinates
|
||||
.split(",")
|
||||
.map((coord) => parseFloat(coord.trim()));
|
||||
return sum + lng;
|
||||
}, 0) / projectsWithCoords.length;
|
||||
|
||||
setMapCenter([avgLat, avgLng]);
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error fetching projects:", error);
|
||||
setLoading(false);
|
||||
});
|
||||
}, [searchParams]);
|
||||
|
||||
// Convert projects to map markers with filtering
|
||||
const markers = projects
|
||||
.filter((project) => project.coordinates)
|
||||
.filter((project) => statusFilters[project.project_status] !== false)
|
||||
.map((project) => {
|
||||
const [lat, lng] = project.coordinates
|
||||
.split(",")
|
||||
.map((coord) => parseFloat(coord.trim()));
|
||||
if (isNaN(lat) || isNaN(lng)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const statusInfo =
|
||||
statusConfig[project.project_status] || statusConfig.registered;
|
||||
|
||||
return {
|
||||
position: [lat, lng],
|
||||
color: statusInfo.color,
|
||||
popup: (
|
||||
<div className="min-w-72 max-w-80">
|
||||
<div className="mb-3 pb-2 border-b border-gray-200">
|
||||
<h3 className="font-semibold text-base mb-1 text-gray-900">
|
||||
{project.project_name}
|
||||
</h3>
|
||||
{project.project_number && (
|
||||
<div className="inline-block bg-blue-100 text-blue-800 text-xs font-medium px-2 py-1 rounded-full">
|
||||
{project.project_number}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-sm text-gray-600 mb-3">
|
||||
{project.address && (
|
||||
<div className="flex items-start gap-2">
|
||||
<svg
|
||||
className="w-4 h-4 mt-0.5 text-gray-400 flex-shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
<div>
|
||||
<span className="font-medium text-gray-700">
|
||||
{project.address}
|
||||
</span>
|
||||
{project.city && (
|
||||
<span className="text-gray-500">, {project.city}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{project.wp && (
|
||||
<div>
|
||||
<span className="font-medium text-gray-700">WP:</span>{" "}
|
||||
{project.wp}
|
||||
</div>
|
||||
)}
|
||||
{project.plot && (
|
||||
<div>
|
||||
<span className="font-medium text-gray-700">Plot:</span>{" "}
|
||||
{project.plot}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{project.project_status && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-gray-700">Status:</span>
|
||||
<span
|
||||
className="inline-block px-2 py-1 rounded-full text-xs font-medium text-white"
|
||||
style={{ backgroundColor: statusInfo.color }}
|
||||
>
|
||||
{statusInfo.shortLabel}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pt-2 border-t border-gray-200">
|
||||
<Link href={`/projects/${project.project_id}`}>
|
||||
<Button variant="primary" size="sm" className="w-full">
|
||||
<svg
|
||||
className="w-4 h-4 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||
/>
|
||||
</svg>
|
||||
View Project Details
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
})
|
||||
.filter((marker) => marker !== null);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 mx-auto mb-4 border-4 border-blue-200 border-t-blue-600 rounded-full animate-spin"></div>
|
||||
<p className="text-gray-600 font-medium">Loading projects map...</p>
|
||||
<p className="text-sm text-gray-500 mt-2">
|
||||
Preparing your full-screen map experience
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="fixed inset-0 bg-gray-50 overflow-hidden">
|
||||
{/* Floating Header - Left Side */}
|
||||
<div className="absolute top-4 left-4 z-[1000]">
|
||||
{/* Title Box */}
|
||||
<div className="bg-white/95 backdrop-blur-sm rounded-lg shadow-lg px-4 py-3 border border-gray-200">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-lg font-semibold text-gray-900">
|
||||
Projects Map
|
||||
</h1>
|
||||
<div className="text-sm text-gray-600">
|
||||
{markers.length} of {projects.length} projects with coordinates
|
||||
</div>
|
||||
</div>{" "}
|
||||
</div>
|
||||
</div>
|
||||
{/* Zoom Controls - Below Title */}
|
||||
<div className="absolute top-20 left-4 z-[1000]">
|
||||
<div className="bg-white/95 backdrop-blur-sm rounded-lg shadow-lg border border-gray-200 flex flex-col">
|
||||
<button
|
||||
className="px-3 py-2 hover:bg-gray-50 transition-colors duration-200 border-b border-gray-200 text-gray-700 font-medium text-lg"
|
||||
onClick={() => {
|
||||
// This will be handled by the map component
|
||||
const event = new CustomEvent("mapZoomIn");
|
||||
window.dispatchEvent(event);
|
||||
}}
|
||||
title="Zoom In"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
className="px-3 py-2 hover:bg-gray-50 transition-colors duration-200 text-gray-700 font-medium text-lg"
|
||||
onClick={() => {
|
||||
// This will be handled by the map component
|
||||
const event = new CustomEvent("mapZoomOut");
|
||||
window.dispatchEvent(event);
|
||||
}}
|
||||
title="Zoom Out"
|
||||
>
|
||||
−
|
||||
</button>{" "}
|
||||
</div>
|
||||
</div>{" "}
|
||||
{/* Tool Panel - Below Zoom Controls */}
|
||||
<div className="absolute top-48 left-4 z-[1000]">
|
||||
{" "}
|
||||
<div className="bg-white/95 backdrop-blur-sm rounded-lg shadow-lg border border-gray-200 flex flex-col">
|
||||
{" "}
|
||||
{/* Move Tool */}
|
||||
<button
|
||||
className={`p-3 transition-colors duration-200 border-b border-gray-200 ${
|
||||
currentTool === "move"
|
||||
? "bg-blue-100 text-blue-700"
|
||||
: "text-gray-700 hover:bg-gray-50"
|
||||
}`}
|
||||
onClick={() => setCurrentTool("move")}
|
||||
title="Move Tool (Pan Map)"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 512 512">
|
||||
<path d="M256 0c-25.3 0-47.2 14.7-57.6 36c-7-2.6-14.5-4-22.4-4c-35.3 0-64 28.7-64 64l0 165.5-2.7-2.7c-25-25-65.5-25-90.5 0s-25 65.5 0 90.5L106.5 437c48 48 113.1 75 181 75l8.5 0 8 0c1.5 0 3-.1 4.5-.4c91.7-6.2 165-79.4 171.1-171.1c.3-1.5 .4-3 .4-4.5l0-176c0-35.3-28.7-64-64-64c-5.5 0-10.9 .7-16 2l0-2c0-35.3-28.7-64-64-64c-7.9 0-15.4 1.4-22.4 4C303.2 14.7 281.3 0 256 0zM240 96.1l0-.1 0-32c0-8.8 7.2-16 16-16s16 7.2 16 16l0 31.9 0 .1 0 136c0 13.3 10.7 24 24 24s24-10.7 24-24l0-136c0 0 0 0 0-.1c0-8.8 7.2-16 16-16s16 7.2 16 16l0 55.9c0 0 0 .1 0 .1l0 80c0 13.3 10.7 24 24 24s24-10.7 24-24l0-71.9c0 0 0-.1 0-.1c0-8.8 7.2-16 16-16s16 7.2 16 16l0 172.9c-.1 .6-.1 1.3-.2 1.9c-3.4 69.7-59.3 125.6-129 129c-.6 0-1.3 .1-1.9 .2l-4.9 0-8.5 0c-55.2 0-108.1-21.9-147.1-60.9L52.7 315.3c-6.2-6.2-6.2-16.4 0-22.6s16.4-6.2 22.6 0L119 336.4c6.9 6.9 17.2 8.9 26.2 5.2s14.8-12.5 14.8-22.2L160 96c0-8.8 7.2-16 16-16c8.8 0 16 7.1 16 15.9L192 232c0 13.3 10.7 24 24 24s24-10.7 24-24l0-135.9z" />
|
||||
</svg>
|
||||
</button>
|
||||
{/* Select Tool */}
|
||||
<button
|
||||
className={`p-3 transition-colors duration-200 border-b border-gray-200 ${
|
||||
currentTool === "select"
|
||||
? "bg-blue-100 text-blue-700"
|
||||
: "text-gray-700 hover:bg-gray-50"
|
||||
}`}
|
||||
onClick={() => setCurrentTool("select")}
|
||||
title="Select Tool"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 15l-2 5L9 9l11 4-5 2zm0 0l5 5M7.188 2.239l.777 2.897M5.136 7.965l-2.898-.777M13.95 4.05l-2.122 2.122m-5.657 5.656l-2.12 2.122"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/* Measure Tool */}
|
||||
<button
|
||||
className={`p-3 transition-colors duration-200 border-b border-gray-200 ${
|
||||
currentTool === "measure"
|
||||
? "bg-blue-100 text-blue-700"
|
||||
: "text-gray-700 hover:bg-gray-50"
|
||||
}`}
|
||||
onClick={() => setCurrentTool("measure")}
|
||||
title="Measure Distance"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M7 21l10-10M7 21H3v-4l10-10 4 4M7 21l4-4M17 7l4-4M17 7l-4-4M17 7l-4 4"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/* Draw Tool */}
|
||||
<button
|
||||
className={`p-3 transition-colors duration-200 border-b border-gray-200 ${
|
||||
currentTool === "draw"
|
||||
? "bg-blue-100 text-blue-700"
|
||||
: "text-gray-700 hover:bg-gray-50"
|
||||
}`}
|
||||
onClick={() => setCurrentTool("draw")}
|
||||
title="Draw/Markup"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/* Pin/Marker Tool */}
|
||||
<button
|
||||
className={`p-3 transition-colors duration-200 border-b border-gray-200 ${
|
||||
currentTool === "pin"
|
||||
? "bg-blue-100 text-blue-700"
|
||||
: "text-gray-700 hover:bg-gray-50"
|
||||
}`}
|
||||
onClick={() => setCurrentTool("pin")}
|
||||
title="Add Pin/Marker"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/* Area Tool */}
|
||||
<button
|
||||
className={`p-3 transition-colors duration-200 ${
|
||||
currentTool === "area"
|
||||
? "bg-blue-100 text-blue-700"
|
||||
: "text-gray-700 hover:bg-gray-50"
|
||||
}`}
|
||||
onClick={() => setCurrentTool("area")}
|
||||
title="Measure Area"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 8V6a2 2 0 012-2h2M4 16v2a2 2 0 002 2h2m8-16h2a2 2 0 012 2v2m-4 12h2a2 2 0 002-2v-2"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Layer Control Panel - Right Side */}
|
||||
<div className="absolute top-4 right-4 z-[1000] flex flex-col gap-3">
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Link href="/projects">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="bg-white/95 backdrop-blur-sm border-gray-200 shadow-lg hover:bg-white"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 6h16M4 10h16M4 14h16M4 18h16"
|
||||
/>
|
||||
</svg>
|
||||
List View
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/projects/new">
|
||||
<Button variant="primary" size="sm" className="shadow-lg">
|
||||
<svg
|
||||
className="w-4 h-4 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
Add Project
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Layer Control Panel */}
|
||||
<div className="bg-white/95 backdrop-blur-sm rounded-lg shadow-lg border border-gray-200 layer-panel-container">
|
||||
{/* Layer Control Header */}
|
||||
<div className="px-4 py-3 border-b border-gray-200">
|
||||
<button
|
||||
onClick={toggleLayerPanel}
|
||||
className="flex items-center justify-between w-full text-left layer-toggle-button"
|
||||
title="Toggle Layer Controls"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<svg
|
||||
className="w-4 h-4 text-gray-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
Map Layers
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 bg-gray-100 px-2 py-0.5 rounded-full">
|
||||
{1 + activeOverlays.length} active
|
||||
</span>
|
||||
</div>
|
||||
<svg
|
||||
className={`w-4 h-4 text-gray-400 transition-transform duration-200 ${
|
||||
showLayerPanel ? "rotate-180" : ""
|
||||
}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>{" "}
|
||||
{/* Layer Control Content */}
|
||||
<div
|
||||
className={`transition-all duration-300 ease-in-out ${
|
||||
showLayerPanel
|
||||
? "max-h-[70vh] opacity-100 overflow-visible"
|
||||
: "max-h-0 opacity-0 overflow-hidden"
|
||||
}`}
|
||||
>
|
||||
<div className="p-4 min-w-80 max-w-96 max-h-[60vh] overflow-y-auto">
|
||||
{/* Base Layers Section */}
|
||||
<div className="mb-4">
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-3 flex items-center gap-2">
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
Base Maps
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{mapLayers.base.map((layer, index) => (
|
||||
<label
|
||||
key={index}
|
||||
className="flex items-center gap-3 p-2 rounded hover:bg-gray-50 cursor-pointer transition-colors duration-200"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="baseLayer"
|
||||
checked={activeBaseLayer === layer.name}
|
||||
onChange={() => handleBaseLayerChange(layer.name)}
|
||||
className="w-4 h-4 text-blue-600 border-gray-300 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 flex-1">
|
||||
{layer.name}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Overlay Layers Section */}
|
||||
{mapLayers.overlays && mapLayers.overlays.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-3 flex items-center gap-2">
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10"
|
||||
/>
|
||||
</svg>
|
||||
Overlay Layers
|
||||
</h3>{" "}
|
||||
<div className="space-y-2">
|
||||
{mapLayers.overlays.map((layer, index) => (
|
||||
<label
|
||||
key={index}
|
||||
className="flex items-center gap-3 p-2 rounded hover:bg-gray-50 cursor-pointer transition-colors duration-200"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={activeOverlays.includes(layer.name)}
|
||||
onChange={() => toggleOverlay(layer.name)}
|
||||
className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 flex-1">
|
||||
{layer.name}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>{" "}
|
||||
</div>
|
||||
</div>
|
||||
{/* Status Filter Panel - Bottom Left */}
|
||||
<div className="absolute bottom-4 left-4 z-[1000]">
|
||||
<div className="bg-white/95 backdrop-blur-sm rounded-lg shadow-lg px-4 py-3 border border-gray-200">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-700 mr-2">
|
||||
Filters:
|
||||
</span>
|
||||
{/* Toggle All Button */}
|
||||
<button
|
||||
onClick={toggleAllFilters}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded text-xs font-medium bg-gray-100 hover:bg-gray-200 transition-colors duration-200 mr-2"
|
||||
title="Toggle all filters"
|
||||
>
|
||||
<svg
|
||||
className="w-3 h-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 6h16M4 12h16M4 18h16"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-gray-600">
|
||||
{Object.values(statusFilters).every((v) => v)
|
||||
? "Hide All"
|
||||
: "Show All"}
|
||||
</span>
|
||||
</button>
|
||||
{/* Individual Status Filters */}
|
||||
{Object.entries(statusConfig).map(([status, config]) => {
|
||||
const isActive = statusFilters[status];
|
||||
const projectCount = projects.filter(
|
||||
(p) => p.project_status === status && p.coordinates
|
||||
).length;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={status}
|
||||
onClick={() => toggleStatusFilter(status)}
|
||||
className={`flex items-center gap-1 px-2 py-1 rounded text-xs font-medium transition-all duration-200 hover:bg-gray-100 ${
|
||||
isActive ? "opacity-100 scale-100" : "opacity-40 scale-95"
|
||||
}`}
|
||||
title={`Toggle ${config.label} (${projectCount} projects)`}
|
||||
>
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full border-2 transition-all duration-200 ${
|
||||
isActive ? "border-white shadow-sm" : "border-gray-300"
|
||||
}`}
|
||||
style={{
|
||||
backgroundColor: isActive ? config.color : "#e5e7eb",
|
||||
}}
|
||||
></div>
|
||||
<span
|
||||
className={`transition-colors duration-200 ${
|
||||
isActive ? "text-gray-700" : "text-gray-400"
|
||||
}`}
|
||||
>
|
||||
{config.shortLabel}
|
||||
</span>
|
||||
<span
|
||||
className={`ml-1 text-xs transition-colors duration-200 ${
|
||||
isActive ? "text-gray-500" : "text-gray-300"
|
||||
}`}
|
||||
>
|
||||
({projectCount})
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}{" "}
|
||||
</div>
|
||||
</div>
|
||||
</div>{" "}
|
||||
{/* Status Panel - Bottom Left */}
|
||||
{markers.length > 0 && (
|
||||
<div className="bg-white/95 backdrop-blur-sm rounded-lg shadow-lg px-4 py-3 border border-gray-200">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-700 mr-2">
|
||||
Filters:
|
||||
</span>
|
||||
|
||||
{/* Toggle All Button */}
|
||||
<button
|
||||
onClick={toggleAllFilters}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded text-xs font-medium bg-gray-100 hover:bg-gray-200 transition-colors duration-200 mr-2"
|
||||
title="Toggle all filters"
|
||||
>
|
||||
<svg
|
||||
className="w-3 h-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 6h16M4 12h16M4 18h16"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-gray-600">
|
||||
{Object.values(statusFilters).every((v) => v)
|
||||
? "Hide All"
|
||||
: "Show All"}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Individual Status Filters */}
|
||||
{Object.entries(statusConfig).map(([status, config]) => {
|
||||
const isActive = statusFilters[status];
|
||||
const projectCount = projects.filter(
|
||||
(p) => p.project_status === status && p.coordinates
|
||||
).length;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={status}
|
||||
onClick={() => toggleStatusFilter(status)}
|
||||
className={`flex items-center gap-1 px-2 py-1 rounded text-xs font-medium transition-all duration-200 hover:bg-gray-100 ${
|
||||
isActive ? "opacity-100 scale-100" : "opacity-40 scale-95"
|
||||
}`}
|
||||
title={`Toggle ${config.label} (${projectCount} projects)`}
|
||||
>
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full border-2 transition-all duration-200 ${
|
||||
isActive ? "border-white shadow-sm" : "border-gray-300"
|
||||
}`}
|
||||
style={{
|
||||
backgroundColor: isActive ? config.color : "#e5e7eb",
|
||||
}}
|
||||
></div>
|
||||
<span
|
||||
className={`transition-colors duration-200 ${
|
||||
isActive ? "text-gray-700" : "text-gray-400"
|
||||
}`}
|
||||
>
|
||||
{config.shortLabel}
|
||||
</span>
|
||||
<span
|
||||
className={`ml-1 text-xs transition-colors duration-200 ${
|
||||
isActive ? "text-gray-500" : "text-gray-300"
|
||||
}`}
|
||||
>
|
||||
({projectCount})
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}{" "}
|
||||
{/* Full Screen Map */}
|
||||
{markers.length === 0 ? (
|
||||
<div className="h-full w-full flex items-center justify-center bg-gray-100">
|
||||
<div className="text-center max-w-md mx-auto p-8 bg-white rounded-lg shadow-lg">
|
||||
<div className="text-gray-400 mb-4">
|
||||
<svg
|
||||
className="w-16 h-16 mx-auto"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
No projects with coordinates
|
||||
</h3>
|
||||
<p className="text-gray-500 mb-6">
|
||||
Projects need coordinates to appear on the map. Add coordinates
|
||||
when creating or editing projects.
|
||||
</p>
|
||||
<div className="flex gap-3 justify-center">
|
||||
<Link href="/projects">
|
||||
<Button variant="outline">View All Projects</Button>
|
||||
</Link>
|
||||
<Link href="/projects/new">
|
||||
<Button variant="primary">Add Project</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="absolute inset-0">
|
||||
<DynamicMap
|
||||
center={mapCenter}
|
||||
zoom={mapZoom}
|
||||
markers={markers}
|
||||
showLayerControl={false}
|
||||
defaultLayer={activeBaseLayer}
|
||||
activeOverlays={activeOverlays}
|
||||
onViewChange={handleMapViewChange}
|
||||
/>
|
||||
</div>
|
||||
)}{" "}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useEffect, useState, Suspense } from "react";
|
||||
import Link from "next/link";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
@@ -17,7 +17,7 @@ const DynamicMap = dynamic(() => import("@/components/ui/LeafletMap"), {
|
||||
),
|
||||
});
|
||||
|
||||
export default function ProjectsMapPage() {
|
||||
function ProjectsMapPageContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const [projects, setProjects] = useState([]);
|
||||
@@ -541,7 +541,7 @@ export default function ProjectsMapPage() {
|
||||
{/* Layer Control Panel - Right Side */}
|
||||
<div className="absolute top-4 right-4 z-[1000] flex flex-col gap-3">
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-2">
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Link href="/projects">
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -926,3 +926,18 @@ export default function ProjectsMapPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProjectsMapPage() {
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto mb-4"></div>
|
||||
<p className="text-gray-600">Loading map...</p>
|
||||
</div>
|
||||
</div>
|
||||
}>
|
||||
<ProjectsMapPageContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -195,7 +195,13 @@ export default function ProjectListPage() {
|
||||
</th>
|
||||
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 w-24">
|
||||
Status
|
||||
</th>{" "}
|
||||
</th>
|
||||
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 w-24">
|
||||
Created By
|
||||
</th>
|
||||
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 w-24">
|
||||
Assigned To
|
||||
</th>
|
||||
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 w-20">
|
||||
Actions
|
||||
</th>
|
||||
@@ -275,6 +281,18 @@ export default function ProjectListPage() {
|
||||
? "Zakończony"
|
||||
: "-"}
|
||||
</td>
|
||||
<td
|
||||
className="px-2 py-3 text-xs text-gray-600 truncate"
|
||||
title={project.created_by_name || "Unknown"}
|
||||
>
|
||||
{project.created_by_name || "Unknown"}
|
||||
</td>
|
||||
<td
|
||||
className="px-2 py-3 text-xs text-gray-600 truncate"
|
||||
title={project.assigned_to_name || "Unassigned"}
|
||||
>
|
||||
{project.assigned_to_name || "Unassigned"}
|
||||
</td>
|
||||
<td className="px-2 py-3">
|
||||
<Link href={`/projects/${project.project_id}`}>
|
||||
<Button
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import ImprovedPolishOrthophotoMap from '../../components/ui/ImprovedPolishOrthophotoMap';
|
||||
|
||||
export default function ImprovedPolishOrthophotoPage() {
|
||||
const testMarkers = [
|
||||
{
|
||||
position: [50.0647, 19.9450], // Krakow
|
||||
popup: "Kraków - Testing WMTS"
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold text-gray-800 mb-6">
|
||||
Improved Polish WMTS Implementation
|
||||
</h1>
|
||||
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4 mb-6">
|
||||
<h2 className="text-lg font-semibold text-green-800 mb-2">
|
||||
Custom WMTS Layer Implementation
|
||||
</h2>
|
||||
<p className="text-green-700">
|
||||
This version uses a custom WMTS layer that properly constructs KVP URLs based on the GetCapabilities response.
|
||||
Check the debug panel on the map to see the actual requests being made.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-lg overflow-hidden">
|
||||
<div className="p-4 bg-blue-600 text-white">
|
||||
<h2 className="text-xl font-semibold">Custom WMTS Layer with Proper KVP URLs</h2>
|
||||
<p className="text-blue-100 mt-2">
|
||||
This implementation builds proper WMTS GetTile requests with all required parameters.
|
||||
Monitor the debug panel and browser network tab for request details.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="h-96 md:h-[600px]">
|
||||
<ImprovedPolishOrthophotoMap
|
||||
center={[50.0647, 19.9450]}
|
||||
zoom={12}
|
||||
markers={testMarkers}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 bg-white rounded-lg shadow-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-800 mb-4">
|
||||
WMTS Parameters Being Tested:
|
||||
</h3>
|
||||
<div className="grid md:grid-cols-2 gap-4 text-sm">
|
||||
<div className="bg-gray-50 p-3 rounded">
|
||||
<strong>Tile Matrix Sets Available:</strong>
|
||||
<ul className="mt-2 space-y-1">
|
||||
<li>• EPSG:3857 (Web Mercator)</li>
|
||||
<li>• EPSG:4326 (WGS84)</li>
|
||||
<li>• EPSG:2180 (Polish National Grid)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 p-3 rounded">
|
||||
<strong>Formats Available:</strong>
|
||||
<ul className="mt-2 space-y-1">
|
||||
<li>• image/jpeg (default)</li>
|
||||
<li>• image/png</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 bg-blue-50 border border-blue-200 rounded-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-blue-800 mb-2">
|
||||
Testing Instructions:
|
||||
</h3>
|
||||
<ol className="text-blue-700 space-y-2">
|
||||
<li><strong>1.</strong> Open Browser Developer Tools (F12) → Network tab</li>
|
||||
<li><strong>2.</strong> Filter by "geoportal.gov.pl" to see WMTS requests</li>
|
||||
<li><strong>3.</strong> Switch between different Polish WMTS options</li>
|
||||
<li><strong>4.</strong> Check if requests return 200 OK or error codes</li>
|
||||
<li><strong>5.</strong> Compare with Google Satellite (known working)</li>
|
||||
<li><strong>6.</strong> Monitor the debug panel for request URLs</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 bg-yellow-50 border border-yellow-200 rounded-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-yellow-800 mb-2">
|
||||
Expected Behavior:
|
||||
</h3>
|
||||
<p className="text-yellow-700">
|
||||
If the Polish orthophoto tiles appear, you should see aerial imagery of Poland.
|
||||
If they don't load, check the network requests - they should show proper WMTS GetTile URLs
|
||||
with all required parameters (SERVICE, REQUEST, LAYER, TILEMATRIXSET, etc.).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from 'react';
|
||||
import PolishOrthophotoMap from '../../components/ui/PolishOrthophotoMap';
|
||||
import AdvancedPolishOrthophotoMap from '../../components/ui/AdvancedPolishOrthophotoMap';
|
||||
|
||||
export default function PolishOrthophotoTestPage() {
|
||||
const [activeMap, setActiveMap] = useState('basic');
|
||||
|
||||
// Test markers - various locations in Poland
|
||||
const testMarkers = [
|
||||
{
|
||||
position: [50.0647, 19.9450], // Krakow
|
||||
popup: "Kraków - Main Market Square"
|
||||
},
|
||||
{
|
||||
position: [52.2297, 21.0122], // Warsaw
|
||||
popup: "Warszawa - Palace of Culture and Science"
|
||||
},
|
||||
{
|
||||
position: [54.3520, 18.6466], // Gdansk
|
||||
popup: "Gdańsk - Old Town"
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold text-gray-800 mb-6">
|
||||
Polish Geoportal Orthophoto Integration
|
||||
</h1>
|
||||
|
||||
{/* Map Type Selector */}
|
||||
<div className="mb-6 bg-white rounded-lg shadow-lg p-4">
|
||||
<h2 className="text-lg font-semibold text-gray-800 mb-3">
|
||||
Choose Map Implementation:
|
||||
</h2>
|
||||
<div className="flex space-x-4">
|
||||
<button
|
||||
onClick={() => setActiveMap('basic')}
|
||||
className={`px-4 py-2 rounded-lg transition-colors ${
|
||||
activeMap === 'basic'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
||||
}`}
|
||||
>
|
||||
Basic Polish Orthophoto
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveMap('advanced')}
|
||||
className={`px-4 py-2 rounded-lg transition-colors ${
|
||||
activeMap === 'advanced'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
||||
}`}
|
||||
>
|
||||
Advanced with WMS Overlays
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Map Container */}
|
||||
<div className="bg-white rounded-lg shadow-lg overflow-hidden">
|
||||
<div className="p-4 bg-blue-600 text-white">
|
||||
<h2 className="text-xl font-semibold">
|
||||
{activeMap === 'basic'
|
||||
? 'Basic Polish Orthophoto Map'
|
||||
: 'Advanced Polish Orthophoto with WMS Overlays'
|
||||
}
|
||||
</h2>
|
||||
<p className="text-blue-100 mt-2">
|
||||
{activeMap === 'basic'
|
||||
? 'Demonstrates working Polish Geoportal orthophoto tiles with multiple base layer options.'
|
||||
: 'Advanced version includes Polish cadastral data (działki) and spatial planning (MPZT) as overlay layers.'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="h-96 md:h-[600px]">
|
||||
{activeMap === 'basic' ? (
|
||||
<PolishOrthophotoMap
|
||||
center={[50.0647, 19.9450]} // Centered on Krakow
|
||||
zoom={12}
|
||||
markers={testMarkers}
|
||||
showLayerControl={true}
|
||||
/>
|
||||
) : (
|
||||
<AdvancedPolishOrthophotoMap
|
||||
center={[50.0647, 19.9450]} // Centered on Krakow
|
||||
zoom={12}
|
||||
markers={testMarkers}
|
||||
showLayerControl={true}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Features Overview */}
|
||||
<div className="mt-8 grid md:grid-cols-2 gap-6">
|
||||
<div className="bg-white rounded-lg shadow-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-800 mb-4">
|
||||
Basic Map Features:
|
||||
</h3>
|
||||
<ul className="space-y-2 text-gray-600">
|
||||
<li className="flex items-center">
|
||||
<span className="w-3 h-3 bg-green-500 rounded-full mr-3"></span>
|
||||
Polish Geoportal Orthophoto (Working)
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="w-3 h-3 bg-blue-500 rounded-full mr-3"></span>
|
||||
OpenStreetMap base layer
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="w-3 h-3 bg-red-500 rounded-full mr-3"></span>
|
||||
Google Satellite imagery
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="w-3 h-3 bg-yellow-500 rounded-full mr-3"></span>
|
||||
Google Roads overlay
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="w-3 h-3 bg-purple-500 rounded-full mr-3"></span>
|
||||
Esri World Imagery
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-800 mb-4">
|
||||
Advanced Map Features:
|
||||
</h3>
|
||||
<ul className="space-y-2 text-gray-600">
|
||||
<li className="flex items-center">
|
||||
<span className="w-3 h-3 bg-green-500 rounded-full mr-3"></span>
|
||||
Standard & High Resolution Orthophoto
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="w-3 h-3 bg-orange-500 rounded-full mr-3"></span>
|
||||
Polish Cadastral Data (WMS)
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="w-3 h-3 bg-teal-500 rounded-full mr-3"></span>
|
||||
Spatial Planning Data (MPZT)
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="w-3 h-3 bg-indigo-500 rounded-full mr-3"></span>
|
||||
Overlay layer support
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="w-3 h-3 bg-pink-500 rounded-full mr-3"></span>
|
||||
Multiple base layers
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Technical Implementation Details */}
|
||||
<div className="mt-8 bg-white rounded-lg shadow-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-800 mb-4">
|
||||
Technical Implementation:
|
||||
</h3>
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-700 mb-2">Key Improvements:</h4>
|
||||
<ul className="text-sm text-gray-600 space-y-1">
|
||||
<li>• Uses REST tile service instead of WMTS for better compatibility</li>
|
||||
<li>• Proper tile size (512px) with zoomOffset=-1</li>
|
||||
<li>• proj4 integration for EPSG:2180 coordinate system</li>
|
||||
<li>• Multiple fallback layers for reliability</li>
|
||||
<li>• WMS overlay support for cadastral data</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-700 mb-2">Based on OpenLayers Code:</h4>
|
||||
<ul className="text-sm text-gray-600 space-y-1">
|
||||
<li>• Converted from OpenLayers to Leaflet implementation</li>
|
||||
<li>• Maintains same layer structure and URLs</li>
|
||||
<li>• Includes Polish projection definitions</li>
|
||||
<li>• Compatible with existing React/Next.js setup</li>
|
||||
<li>• Extensible for additional WMS services</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Usage Instructions */}
|
||||
<div className="mt-8 bg-blue-50 border border-blue-200 rounded-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-blue-800 mb-2">
|
||||
How to Use:
|
||||
</h3>
|
||||
<div className="text-blue-700 space-y-2">
|
||||
<p><strong>1.</strong> Use the layer control (top-right) to switch between base layers</p>
|
||||
<p><strong>2.</strong> In advanced mode, enable overlay layers for cadastral/planning data</p>
|
||||
<p><strong>3.</strong> Click on markers to see location information</p>
|
||||
<p><strong>4.</strong> Zoom in to see high-resolution orthophoto details</p>
|
||||
<p><strong>5.</strong> Combine orthophoto with cadastral overlay for property boundaries</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import PolishOrthophotoMap from '../../components/ui/PolishOrthophotoMap';
|
||||
|
||||
export default function TestPolishOrthophotoPage() {
|
||||
// Test markers - various locations in Poland
|
||||
const testMarkers = [
|
||||
{
|
||||
position: [50.0647, 19.9450], // Krakow
|
||||
popup: "Kraków - Main Market Square"
|
||||
},
|
||||
{
|
||||
position: [52.2297, 21.0122], // Warsaw
|
||||
popup: "Warszawa - Palace of Culture and Science"
|
||||
},
|
||||
{
|
||||
position: [54.3520, 18.6466], // Gdansk
|
||||
popup: "Gdańsk - Old Town"
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold text-gray-800 mb-6">
|
||||
Polish Geoportal Orthophoto Map Test
|
||||
</h1>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-lg overflow-hidden">
|
||||
<div className="p-4 bg-blue-600 text-white">
|
||||
<h2 className="text-xl font-semibold">Interactive Map with Polish Orthophoto</h2>
|
||||
<p className="text-blue-100 mt-2">
|
||||
This map demonstrates working Polish Geoportal orthophoto tiles.
|
||||
Use the layer control (top-right) to switch between different map layers.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="h-96 md:h-[600px]">
|
||||
<PolishOrthophotoMap
|
||||
center={[50.0647, 19.9450]} // Centered on Krakow
|
||||
zoom={12}
|
||||
markers={testMarkers}
|
||||
showLayerControl={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 bg-white rounded-lg shadow-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-800 mb-4">
|
||||
Map Layers Available:
|
||||
</h3>
|
||||
<ul className="space-y-2 text-gray-600">
|
||||
<li className="flex items-center">
|
||||
<span className="w-3 h-3 bg-green-500 rounded-full mr-3"></span>
|
||||
<strong>Polish Geoportal Orthophoto:</strong> High-resolution aerial imagery from Polish Geoportal
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="w-3 h-3 bg-blue-500 rounded-full mr-3"></span>
|
||||
<strong>OpenStreetMap:</strong> Standard OpenStreetMap tiles
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="w-3 h-3 bg-red-500 rounded-full mr-3"></span>
|
||||
<strong>Google Satellite:</strong> Google satellite imagery
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="w-3 h-3 bg-yellow-500 rounded-full mr-3"></span>
|
||||
<strong>Google Roads:</strong> Google road overlay
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="w-3 h-3 bg-purple-500 rounded-full mr-3"></span>
|
||||
<strong>Esri Satellite:</strong> Esri world imagery
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 bg-yellow-50 border border-yellow-200 rounded-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-yellow-800 mb-2">
|
||||
Implementation Notes:
|
||||
</h3>
|
||||
<div className="text-yellow-700 space-y-2">
|
||||
<p>
|
||||
• The Polish Geoportal orthophoto uses REST tile service instead of WMTS for better compatibility
|
||||
</p>
|
||||
<p>
|
||||
• Tile size is set to 512px with zoomOffset=-1 for proper tile alignment
|
||||
</p>
|
||||
<p>
|
||||
• proj4 library is included for coordinate system transformations (EPSG:2180)
|
||||
</p>
|
||||
<p>
|
||||
• Multiple fallback layers are provided for comparison and reliability
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user