feat: Add NoteForm, ProjectForm, and ProjectTaskForm components
- Implemented NoteForm for adding notes to projects. - Created ProjectForm for managing project details with contract selection. - Developed ProjectTaskForm for adding tasks to projects, supporting both templates and custom tasks. feat: Add ProjectTasksSection component - Introduced ProjectTasksSection to display and manage tasks for a specific project. - Includes functionality for adding, updating, and deleting tasks. feat: Create TaskTemplateForm for managing task templates - Added TaskTemplateForm for creating new task templates with required wait days. feat: Implement UI components - Created reusable UI components: Badge, Button, Card, Input, Loading, Navigation. - Enhanced user experience with consistent styling and functionality. feat: Set up database and queries - Initialized SQLite database with tables for contracts, projects, tasks, project tasks, and notes. - Implemented queries for managing contracts, projects, tasks, and notes. chore: Add error handling and loading states - Improved error handling in forms and data fetching. - Added loading states for better user feedback during data operations.
This commit is contained in:
74
src/components/ContractForm.js
Normal file
74
src/components/ContractForm.js
Normal file
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function ContractForm() {
|
||||
const [form, setForm] = useState({
|
||||
contract_number: "",
|
||||
contract_name: "",
|
||||
customer_contract_number: "",
|
||||
customer: "",
|
||||
investor: "",
|
||||
date_signed: "",
|
||||
finish_date: "",
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
function handleChange(e) {
|
||||
setForm({ ...form, [e.target.name]: e.target.value });
|
||||
}
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
console.log("Submitting form:", form);
|
||||
|
||||
const res = await fetch("/api/contracts", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(form),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
router.push("/projects"); // or /contracts if you plan a listing
|
||||
} else {
|
||||
alert(
|
||||
"Wystąpił błąd podczas dodawania umowy. Sprawdź dane i spróbuj ponownie."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{[
|
||||
["contract_number", "Numer Umowy"],
|
||||
["contract_name", "Nazwa Umowy"],
|
||||
["customer_contract_number", "Numer Umowy (Klienta)"],
|
||||
["customer", "Zleceniodawca"],
|
||||
["investor", "Inwestor"],
|
||||
["date_signed", "Data zawarcia"],
|
||||
["finish_date", "Data zakończenia"],
|
||||
].map(([name, label]) => (
|
||||
<div key={name}>
|
||||
<label className="block font-medium">{label}</label>
|
||||
<input
|
||||
type={name.includes("date") ? "date" : "text"}
|
||||
name={name}
|
||||
value={form[name] || ""}
|
||||
onChange={handleChange}
|
||||
className="border p-2 w-full"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded"
|
||||
>
|
||||
Dodaj umowę
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
46
src/components/NoteForm.js
Normal file
46
src/components/NoteForm.js
Normal file
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
|
||||
export default function NoteForm({ projectId }) {
|
||||
const [note, setNote] = useState("");
|
||||
const [status, setStatus] = useState(null);
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const res = await fetch("/api/notes", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ project_id: projectId, note }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setNote("");
|
||||
setStatus("Note added");
|
||||
window.location.reload();
|
||||
} else {
|
||||
setStatus("Failed to save note");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-2 mb-4">
|
||||
<textarea
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder="Add a new note..."
|
||||
className="border p-2 w-full"
|
||||
rows={3}
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded"
|
||||
>
|
||||
Add Note
|
||||
</button>
|
||||
{status && <p className="text-sm text-gray-600">{status}</p>}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
111
src/components/ProjectForm.js
Normal file
111
src/components/ProjectForm.js
Normal file
@@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function ProjectForm({ initialData = null }) {
|
||||
const [form, setForm] = useState({
|
||||
contract_id: "",
|
||||
project_name: "",
|
||||
address: "",
|
||||
plot: "",
|
||||
district: "",
|
||||
unit: "",
|
||||
city: "",
|
||||
investment_number: "",
|
||||
finish_date: "",
|
||||
wp: "",
|
||||
contact: "",
|
||||
notes: "",
|
||||
...initialData,
|
||||
});
|
||||
|
||||
const [contracts, setContracts] = useState([]);
|
||||
const router = useRouter();
|
||||
const isEdit = !!initialData;
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/contracts")
|
||||
.then((res) => res.json())
|
||||
.then(setContracts);
|
||||
}, []);
|
||||
|
||||
function handleChange(e) {
|
||||
setForm({ ...form, [e.target.name]: e.target.value });
|
||||
}
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const res = await fetch(
|
||||
isEdit ? `/api/projects/${initialData.project_id}` : "/api/projects",
|
||||
{
|
||||
method: isEdit ? "PUT" : "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(form),
|
||||
}
|
||||
);
|
||||
|
||||
if (res.ok) {
|
||||
router.push("/projects");
|
||||
} else {
|
||||
alert("Failed to save project.");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Contract Dropdown */}
|
||||
<div>
|
||||
<label className="block font-medium">Umowa</label>
|
||||
<select
|
||||
name="contract_id"
|
||||
value={form.contract_id || ""}
|
||||
onChange={handleChange}
|
||||
className="border p-2 w-full"
|
||||
required
|
||||
>
|
||||
<option value="">Wybierz umowę</option>
|
||||
{contracts.map((contract) => (
|
||||
<option key={contract.contract_id} value={contract.contract_id}>
|
||||
{contract.contract_number} – {contract.contract_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Other fields */}
|
||||
{[
|
||||
["project_name", "Nazwa projektu"],
|
||||
["address", "Lokalizacja"],
|
||||
["plot", "Działka"],
|
||||
["district", "Obręb ewidencyjny"],
|
||||
["unit", "Jednostka ewidencyjna"],
|
||||
["city", "Miejscowość"],
|
||||
["investment_number", "Numer inwestycjny"],
|
||||
["finish_date", "Termin realizacji"],
|
||||
["wp", "WP"],
|
||||
["contact", "Dane kontaktowe"],
|
||||
["notes", "Notatki"],
|
||||
].map(([name, label]) => (
|
||||
<div key={name}>
|
||||
<label className="block font-medium">{label}</label>
|
||||
<input
|
||||
type={name === "finish_date" ? "date" : "text"}
|
||||
name={name}
|
||||
value={form[name] || ""}
|
||||
onChange={handleChange}
|
||||
className="border p-2 w-full"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded"
|
||||
>
|
||||
{isEdit ? "Update" : "Create"} Project
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
178
src/components/ProjectTaskForm.js
Normal file
178
src/components/ProjectTaskForm.js
Normal file
@@ -0,0 +1,178 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Button from "./ui/Button";
|
||||
import Badge from "./ui/Badge";
|
||||
|
||||
export default function ProjectTaskForm({ projectId, onTaskAdded }) {
|
||||
const [taskTemplates, setTaskTemplates] = useState([]);
|
||||
const [taskType, setTaskType] = useState("template"); // "template" or "custom"
|
||||
const [selectedTemplate, setSelectedTemplate] = useState("");
|
||||
const [customTaskName, setCustomTaskName] = useState("");
|
||||
const [customMaxWaitDays, setCustomMaxWaitDays] = useState("");
|
||||
const [priority, setPriority] = useState("normal");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch available task templates
|
||||
fetch("/api/tasks/templates")
|
||||
.then((res) => res.json())
|
||||
.then(setTaskTemplates);
|
||||
}, []);
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
// Validate based on task type
|
||||
if (taskType === "template" && !selectedTemplate) return;
|
||||
if (taskType === "custom" && !customTaskName.trim()) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const requestData = {
|
||||
project_id: parseInt(projectId),
|
||||
priority,
|
||||
};
|
||||
|
||||
if (taskType === "template") {
|
||||
requestData.task_template_id = parseInt(selectedTemplate);
|
||||
} else {
|
||||
requestData.custom_task_name = customTaskName.trim();
|
||||
requestData.custom_max_wait_days = parseInt(customMaxWaitDays) || 0;
|
||||
}
|
||||
|
||||
const res = await fetch("/api/project-tasks", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(requestData),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
// Reset form
|
||||
setSelectedTemplate("");
|
||||
setCustomTaskName("");
|
||||
setCustomMaxWaitDays("");
|
||||
setPriority("normal");
|
||||
if (onTaskAdded) onTaskAdded();
|
||||
} else {
|
||||
alert("Failed to add task to project.");
|
||||
}
|
||||
} catch (error) {
|
||||
alert("Error adding task to project.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-3">
|
||||
Task Type
|
||||
</label>
|
||||
<div className="flex space-x-6">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
value="template"
|
||||
checked={taskType === "template"}
|
||||
onChange={(e) => setTaskType(e.target.value)}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300"
|
||||
/>
|
||||
<span className="ml-2 text-sm text-gray-900">From Template</span>
|
||||
</label>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
value="custom"
|
||||
checked={taskType === "custom"}
|
||||
onChange={(e) => setTaskType(e.target.value)}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300"
|
||||
/>
|
||||
<span className="ml-2 text-sm text-gray-900">Custom Task</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{taskType === "template" ? (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Select Task Template
|
||||
</label>{" "}
|
||||
<select
|
||||
value={selectedTemplate}
|
||||
onChange={(e) => setSelectedTemplate(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
required
|
||||
>
|
||||
<option value="">Choose a task template...</option>
|
||||
{taskTemplates.map((template) => (
|
||||
<option key={template.task_id} value={template.task_id}>
|
||||
{template.name} ({template.max_wait_days} days)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Task Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={customTaskName}
|
||||
onChange={(e) => setCustomTaskName(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="Enter custom task name..."
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Max Wait Days
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={customMaxWaitDays}
|
||||
onChange={(e) => setCustomMaxWaitDays(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="0"
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Priority
|
||||
</label>
|
||||
<select
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="low">Low</option>
|
||||
<option value="normal">Normal</option>
|
||||
<option value="high">High</option>
|
||||
<option value="urgent">Urgent</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={
|
||||
isSubmitting ||
|
||||
(taskType === "template" && !selectedTemplate) ||
|
||||
(taskType === "custom" && !customTaskName.trim())
|
||||
}
|
||||
>
|
||||
{isSubmitting ? "Adding..." : "Add Task"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
241
src/components/ProjectTasksSection.js
Normal file
241
src/components/ProjectTasksSection.js
Normal file
@@ -0,0 +1,241 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import ProjectTaskForm from "./ProjectTaskForm";
|
||||
import { Card, CardHeader, CardContent } from "./ui/Card";
|
||||
import Button from "./ui/Button";
|
||||
import Badge from "./ui/Badge";
|
||||
|
||||
export default function ProjectTasksSection({ projectId }) {
|
||||
const [projectTasks, setProjectTasks] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
useEffect(() => {
|
||||
const fetchProjectTasks = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/project-tasks?project_id=${projectId}`);
|
||||
const tasks = await res.json();
|
||||
setProjectTasks(tasks);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch project tasks:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchProjectTasks();
|
||||
}, [projectId]);
|
||||
|
||||
const refetchTasks = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/project-tasks?project_id=${projectId}`);
|
||||
const tasks = await res.json();
|
||||
setProjectTasks(tasks);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch project tasks:", error);
|
||||
}
|
||||
};
|
||||
const handleTaskAdded = () => {
|
||||
refetchTasks(); // Refresh the list
|
||||
};
|
||||
|
||||
const handleStatusChange = async (taskId, newStatus) => {
|
||||
try {
|
||||
const res = await fetch(`/api/project-tasks/${taskId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status: newStatus }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
refetchTasks(); // Refresh the list
|
||||
} else {
|
||||
alert("Failed to update task status");
|
||||
}
|
||||
} catch (error) {
|
||||
alert("Error updating task status");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteTask = async (taskId) => {
|
||||
if (!confirm("Are you sure you want to delete this task?")) return;
|
||||
try {
|
||||
const res = await fetch(`/api/project-tasks/${taskId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
refetchTasks(); // Refresh the list
|
||||
} else {
|
||||
alert("Failed to delete task");
|
||||
}
|
||||
} catch (error) {
|
||||
alert("Error deleting task");
|
||||
}
|
||||
};
|
||||
const getPriorityVariant = (priority) => {
|
||||
switch (priority) {
|
||||
case "urgent":
|
||||
return "urgent";
|
||||
case "high":
|
||||
return "high";
|
||||
case "normal":
|
||||
return "normal";
|
||||
case "low":
|
||||
return "low";
|
||||
default:
|
||||
return "default";
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusVariant = (status) => {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return "success";
|
||||
case "in_progress":
|
||||
return "primary";
|
||||
case "pending":
|
||||
return "warning";
|
||||
case "cancelled":
|
||||
return "danger";
|
||||
default:
|
||||
return "default";
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold text-gray-900">Project Tasks</h2>
|
||||
<Badge variant="default" className="text-sm">
|
||||
{projectTasks.length} {projectTasks.length === 1 ? "task" : "tasks"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Add New Task Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-medium text-gray-900">Add New Task</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ProjectTaskForm
|
||||
projectId={projectId}
|
||||
onTaskAdded={handleTaskAdded}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Current Tasks */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-medium text-gray-900">Current Tasks</h3>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{loading ? (
|
||||
<div className="p-6 text-center">
|
||||
<div className="animate-pulse">
|
||||
<div className="h-4 bg-gray-200 rounded w-1/4 mx-auto mb-2"></div>
|
||||
<div className="h-3 bg-gray-200 rounded w-1/6 mx-auto"></div>
|
||||
</div>
|
||||
</div>
|
||||
) : projectTasks.length === 0 ? (
|
||||
<div className="p-6 text-center">
|
||||
<div className="text-gray-400 mb-2">
|
||||
<svg
|
||||
className="w-12 h-12 mx-auto"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M3 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1V4zm0 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1V8zm0 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1v-2z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-gray-500 text-sm">
|
||||
No tasks assigned to this project yet.
|
||||
</p>
|
||||
<p className="text-gray-400 text-xs mt-1">
|
||||
Add a task above to get started.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-200">
|
||||
{projectTasks.map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
className="p-6 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<h4 className="text-base font-medium text-gray-900 truncate">
|
||||
{task.task_name}
|
||||
</h4>
|
||||
<Badge
|
||||
variant={getPriorityVariant(task.priority)}
|
||||
size="sm"
|
||||
>
|
||||
{task.priority}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-sm text-gray-600 mb-4">
|
||||
<div>
|
||||
<span className="font-medium">Max wait days:</span>{" "}
|
||||
{task.max_wait_days}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Type:</span>{" "}
|
||||
{task.task_type || "template"}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Added:</span>{" "}
|
||||
{new Date(task.date_added).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
Status:
|
||||
</span>
|
||||
<select
|
||||
value={task.status}
|
||||
onChange={(e) =>
|
||||
handleStatusChange(task.id, e.target.value)
|
||||
}
|
||||
className="px-3 py-1.5 text-sm border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||
>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="in_progress">In Progress</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
</select>
|
||||
<Badge
|
||||
variant={getStatusVariant(task.status)}
|
||||
size="sm"
|
||||
>
|
||||
{task.status.replace("_", " ")}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ml-4 flex-shrink-0">
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteTask(task.id)}
|
||||
className="text-xs"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
0
src/components/TaskForm.js
Normal file
0
src/components/TaskForm.js
Normal file
61
src/components/TaskTemplateForm.js
Normal file
61
src/components/TaskTemplateForm.js
Normal file
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function TaskTemplateForm() {
|
||||
const [name, setName] = useState("");
|
||||
const [max_wait_days, setRequiredWaitDays] = useState("");
|
||||
const router = useRouter();
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const res = await fetch("/api/tasks", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
max_wait_days: parseInt(max_wait_days, 10) || 0,
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
router.push("/tasks/templates");
|
||||
} else {
|
||||
alert("Failed to create task template.");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block font-medium">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="border p-2 w-full"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block font-medium">Required Wait Days</label>
|
||||
<input
|
||||
type="number"
|
||||
name="max_wait_days"
|
||||
value={max_wait_days}
|
||||
onChange={(e) => setRequiredWaitDays(e.target.value)}
|
||||
className="border p-2 w-full"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded"
|
||||
>
|
||||
Create Template
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
41
src/components/ui/Badge.js
Normal file
41
src/components/ui/Badge.js
Normal file
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
const Badge = ({
|
||||
children,
|
||||
variant = "default",
|
||||
size = "sm",
|
||||
className = "",
|
||||
}) => {
|
||||
const variants = {
|
||||
default: "bg-gray-100 text-gray-800",
|
||||
primary: "bg-blue-100 text-blue-800",
|
||||
success: "bg-green-100 text-green-800",
|
||||
warning: "bg-yellow-100 text-yellow-800",
|
||||
danger: "bg-red-100 text-red-800",
|
||||
urgent: "bg-red-500 text-white",
|
||||
high: "bg-orange-500 text-white",
|
||||
normal: "bg-blue-500 text-white",
|
||||
low: "bg-gray-500 text-white",
|
||||
};
|
||||
|
||||
const sizes = {
|
||||
xs: "px-2 py-0.5 text-xs",
|
||||
sm: "px-2.5 py-0.5 text-xs",
|
||||
md: "px-3 py-1 text-sm",
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`
|
||||
inline-flex items-center rounded-full font-medium
|
||||
${variants[variant]}
|
||||
${sizes[size]}
|
||||
${className}
|
||||
`}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default Badge;
|
||||
54
src/components/ui/Button.js
Normal file
54
src/components/ui/Button.js
Normal file
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { forwardRef } from "react";
|
||||
|
||||
const buttonVariants = {
|
||||
primary: "bg-blue-600 hover:bg-blue-700 text-white",
|
||||
secondary:
|
||||
"bg-gray-100 hover:bg-gray-200 text-gray-900 border border-gray-300",
|
||||
danger: "bg-red-600 hover:bg-red-700 text-white",
|
||||
success: "bg-green-600 hover:bg-green-700 text-white",
|
||||
outline: "border border-blue-600 text-blue-600 hover:bg-blue-50",
|
||||
};
|
||||
|
||||
const buttonSizes = {
|
||||
sm: "px-3 py-1.5 text-sm",
|
||||
md: "px-4 py-2 text-sm",
|
||||
lg: "px-6 py-3 text-base",
|
||||
};
|
||||
|
||||
const Button = forwardRef(
|
||||
(
|
||||
{
|
||||
children,
|
||||
variant = "primary",
|
||||
size = "md",
|
||||
disabled = false,
|
||||
className = "",
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
className={`
|
||||
inline-flex items-center justify-center rounded-lg font-medium transition-colors
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
${buttonVariants[variant]}
|
||||
${buttonSizes[size]}
|
||||
${className}
|
||||
`}
|
||||
disabled={disabled}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Button.displayName = "Button";
|
||||
|
||||
export default Button;
|
||||
42
src/components/ui/Card.js
Normal file
42
src/components/ui/Card.js
Normal file
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { forwardRef } from "react";
|
||||
|
||||
const Card = forwardRef(({ children, className = "", ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`
|
||||
bg-white rounded-lg border border-gray-200 shadow-sm
|
||||
${className}
|
||||
`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
Card.displayName = "Card";
|
||||
|
||||
const CardHeader = ({ children, className = "" }) => {
|
||||
return (
|
||||
<div className={`px-6 py-4 border-b border-gray-200 ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CardContent = ({ children, className = "" }) => {
|
||||
return <div className={`px-6 py-4 ${className}`}>{children}</div>;
|
||||
};
|
||||
|
||||
const CardFooter = ({ children, className = "" }) => {
|
||||
return (
|
||||
<div className={`px-6 py-4 border-t border-gray-200 ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { Card, CardHeader, CardContent, CardFooter };
|
||||
104
src/components/ui/Input.js
Normal file
104
src/components/ui/Input.js
Normal file
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import { forwardRef } from "react";
|
||||
|
||||
const Input = forwardRef(
|
||||
({ label, error, className = "", type = "text", ...props }, ref) => {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{label && (
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<input
|
||||
ref={ref}
|
||||
type={type}
|
||||
className={`
|
||||
w-full px-3 py-2 border border-gray-300 rounded-lg
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500
|
||||
disabled:bg-gray-50 disabled:text-gray-500
|
||||
${
|
||||
error
|
||||
? "border-red-300 focus:ring-red-500 focus:border-red-500"
|
||||
: ""
|
||||
}
|
||||
${className}
|
||||
`}
|
||||
{...props}
|
||||
/>
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Input.displayName = "Input";
|
||||
|
||||
const Select = forwardRef(
|
||||
({ label, error, children, className = "", ...props }, ref) => {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{label && (
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<select
|
||||
ref={ref}
|
||||
className={`
|
||||
w-full px-3 py-2 border border-gray-300 rounded-lg
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500
|
||||
disabled:bg-gray-50 disabled:text-gray-500
|
||||
${
|
||||
error
|
||||
? "border-red-300 focus:ring-red-500 focus:border-red-500"
|
||||
: ""
|
||||
}
|
||||
${className}
|
||||
`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</select>
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Select.displayName = "Select";
|
||||
|
||||
const Textarea = forwardRef(
|
||||
({ label, error, className = "", ...props }, ref) => {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{label && (
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<textarea
|
||||
ref={ref}
|
||||
className={`
|
||||
w-full px-3 py-2 border border-gray-300 rounded-lg
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500
|
||||
disabled:bg-gray-50 disabled:text-gray-500
|
||||
${
|
||||
error
|
||||
? "border-red-300 focus:ring-red-500 focus:border-red-500"
|
||||
: ""
|
||||
}
|
||||
${className}
|
||||
`}
|
||||
{...props}
|
||||
/>
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Textarea.displayName = "Textarea";
|
||||
|
||||
export { Input, Select, Textarea };
|
||||
39
src/components/ui/Loading.js
Normal file
39
src/components/ui/Loading.js
Normal file
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
const LoadingSpinner = ({ size = "md", className = "" }) => {
|
||||
const sizes = {
|
||||
sm: "w-4 h-4",
|
||||
md: "w-6 h-6",
|
||||
lg: "w-8 h-8",
|
||||
xl: "w-12 h-12",
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`animate-spin rounded-full border-2 border-gray-300 border-t-blue-600 ${sizes[size]} ${className}`}
|
||||
></div>
|
||||
);
|
||||
};
|
||||
|
||||
const LoadingCard = ({ className = "" }) => (
|
||||
<div className={`animate-pulse ${className}`}>
|
||||
<div className="bg-white rounded-lg border border-gray-200 shadow-sm p-6">
|
||||
<div className="space-y-3">
|
||||
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
|
||||
<div className="h-3 bg-gray-200 rounded w-1/2"></div>
|
||||
<div className="h-3 bg-gray-200 rounded w-2/3"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const LoadingList = ({ items = 3, className = "" }) => (
|
||||
<div className={`space-y-4 ${className}`}>
|
||||
{Array.from({ length: items }).map((_, index) => (
|
||||
<LoadingCard key={index} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
export { LoadingSpinner, LoadingCard, LoadingList };
|
||||
export default LoadingSpinner;
|
||||
52
src/components/ui/Navigation.js
Normal file
52
src/components/ui/Navigation.js
Normal file
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
const Navigation = () => {
|
||||
const pathname = usePathname();
|
||||
|
||||
const isActive = (path) => {
|
||||
if (path === "/") return pathname === "/";
|
||||
return pathname.startsWith(path);
|
||||
};
|
||||
|
||||
const navItems = [
|
||||
{ href: "/", label: "Dashboard" },
|
||||
{ href: "/projects", label: "Projects" },
|
||||
{ href: "/tasks/templates", label: "Task Templates" },
|
||||
{ href: "/contracts", label: "Contracts" },
|
||||
];
|
||||
|
||||
return (
|
||||
<nav className="bg-white border-b border-gray-200">
|
||||
<div className="max-w-6xl mx-auto px-6">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
<div className="flex items-center">
|
||||
<Link href="/" className="text-xl font-bold text-gray-900">
|
||||
Project Panel
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-8">
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
isActive(item.href)
|
||||
? "bg-blue-100 text-blue-700"
|
||||
: "text-gray-600 hover:text-gray-900 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
export default Navigation;
|
||||
Reference in New Issue
Block a user