mike/frontend/src/app/components/projects/NewProjectModal.tsx
2026-07-16 20:07:12 +08:00

329 lines
13 KiB
TypeScript

"use client";
import { useRef, useState } from "react";
import { Upload, User, X } from "lucide-react";
import {
addDocumentToProject,
createProject,
uploadProjectDocument,
} from "@/app/lib/mikeApi";
import { FileDirectory } from "../shared/FileDirectory";
import { AddUserInput } from "../shared/AddUserInput";
import type { Document, Project } from "../shared/types";
import type { UserLookupResult } from "@/app/lib/mikeApi";
import { useAuth } from "@/app/contexts/AuthContext";
import { Modal } from "../modals/Modal";
import { ModalFieldLabel } from "../modals/ModalFieldLabel";
import { ModalTextInput } from "../modals/ModalTextInput";
import { ProjectPracticeField } from "./ProjectPracticeField";
interface Props {
open: boolean;
onClose: () => void;
onCreated: (project: Project) => void;
}
export function NewProjectModal({ open, onClose, onCreated }: Props) {
const [step, setStep] = useState<"details" | "documents">("details");
const [name, setName] = useState("");
const [cmNumber, setCmNumber] = useState("");
const [practice, setPractice] = useState("");
const [sharedUsers, setSharedUsers] = useState<UserLookupResult[]>([]);
const [selectedDocuments, setSelectedDocuments] = useState<Document[]>([]);
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const fileInputRef = useRef<HTMLInputElement>(null);
const { user } = useAuth();
const ownEmail = user?.email?.trim().toLowerCase() ?? null;
const formId = "new-project-modal-form";
if (!open) return null;
function submitterValue(e: React.FormEvent<HTMLFormElement>) {
return (
(e.nativeEvent as SubmitEvent).submitter as
| HTMLButtonElement
| null
)?.value;
}
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const files = Array.from(e.target.files ?? []);
e.target.value = "";
if (!files.length) return;
setPendingFiles((prev) => [...prev, ...files.filter((f) => !prev.some((p) => p.name === f.name))]);
}
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
if (!name.trim()) return;
if (step === "details" || submitterValue(e) !== "create-project") {
setStep("documents");
return;
}
setLoading(true);
setError("");
try {
const project = await createProject(
name.trim(),
cmNumber.trim() || undefined,
practice.trim() && practice.trim() !== "Other"
? practice.trim()
: undefined,
ownEmail
? sharedUsers
.map((user) => user.email)
.filter((email) => email !== ownEmail)
: sharedUsers.map((user) => user.email),
);
await Promise.all([
...selectedDocuments.map((document) =>
addDocumentToProject(project.id, document.id).catch(() => {}),
),
...pendingFiles.map((f) => uploadProjectDocument(project.id, f).catch(() => {})),
]);
onCreated({
...project,
document_count: selectedDocuments.length + pendingFiles.length,
});
resetForm();
onClose();
} catch (err: unknown) {
setError((err as Error).message || "Failed to create project");
} finally {
setLoading(false);
}
}
function resetForm() {
setStep("details");
setName("");
setCmNumber("");
setPractice("");
setSharedUsers([]);
setSelectedDocuments([]);
setPendingFiles([]);
setError("");
}
function handleClose() {
resetForm();
onClose();
}
function validateShareUser(email: string) {
if (ownEmail && email === ownEmail) {
return "You cannot share a project with yourself.";
}
if (
sharedUsers.some(
(user) => user.email.trim().toLowerCase() === email,
)
) {
return `${email} already has access.`;
}
return null;
}
function handleAddShareUser(user: UserLookupResult) {
setSharedUsers((prev) => [
...prev,
{
...user,
email: user.email.trim().toLowerCase(),
},
]);
}
function handleRemoveShareUser(email: string) {
setSharedUsers((prev) =>
prev.filter(
(user) =>
user.email.trim().toLowerCase() !==
email.trim().toLowerCase(),
),
);
}
return (
<Modal
open={open}
onClose={handleClose}
breadcrumbs={[
"Projects",
"New project",
step === "details" ? "Details" : "Add Documents",
]}
secondaryAction={
step === "documents"
? {
label: `Upload${pendingFiles.length > 0 ? ` (${pendingFiles.length})` : ""}`,
icon: <Upload className="h-3.5 w-3.5" />,
onClick: () => fileInputRef.current?.click(),
disabled: loading,
}
: undefined
}
cancelAction={
step === "documents"
? {
label: "Back",
onClick: () => setStep("details"),
disabled: loading,
}
: undefined
}
primaryAction={
step === "details"
? {
label: "Next",
type: "button",
onClick: (event) => {
event.preventDefault();
setStep("documents");
},
disabled: !name.trim() || loading,
}
: {
label: loading ? "Creating…" : "Create project",
type: "submit",
form: formId,
name: "modalAction",
value: "create-project",
disabled: !name.trim() || loading,
}
}
>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={handleFileChange}
/>
<form
id={formId}
onSubmit={handleSubmit}
className="flex flex-col flex-1 min-h-0"
>
{step === "details" ? (
<div className="space-y-6">
<div>
<ModalFieldLabel htmlFor="new-project-name">
Project name
</ModalFieldLabel>
<ModalTextInput
id="new-project-name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Add project name"
variant="minimal"
autoFocus
/>
</div>
<div>
<ModalFieldLabel htmlFor="new-project-cm-number">
CM number
</ModalFieldLabel>
<ModalTextInput
id="new-project-cm-number"
type="text"
value={cmNumber}
onChange={(e) => setCmNumber(e.target.value)}
placeholder="Add a CM number..."
variant="minimal"
className="text-xl text-gray-600"
/>
</div>
<div>
<ModalFieldLabel htmlFor="new-project-practice">
Practice
</ModalFieldLabel>
<ProjectPracticeField
id="new-project-practice"
value={practice}
onChange={setPractice}
/>
</div>
<div className="space-y-2">
<ModalFieldLabel as="p">
Share with
</ModalFieldLabel>
<AddUserInput
onAdd={handleAddShareUser}
validateEmail={validateShareUser}
placeholder="Add colleagues by email..."
/>
{sharedUsers.length > 0 && (
<ul className="space-y-1 pt-1">
{sharedUsers.map((entry) => {
const displayName =
entry.display_name?.trim();
const primary = displayName || "User";
const initial = displayName
?.charAt(0)
.toUpperCase();
return (
<li
key={entry.email}
className="flex items-center gap-2.5 rounded-lg px-2 py-1.5 transition-colors hover:bg-gray-100/70"
>
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-white/80 bg-white text-gray-700 shadow-[0_4px_12px_rgba(15,23,42,0.10),inset_0_1px_0_rgba(255,255,255,0.92),inset_0_-1px_0_rgba(255,255,255,0.64)]">
{initial ? (
<span className="font-serif text-[11px] leading-none">
{initial}
</span>
) : (
<User className="h-2.5 w-2.5" />
)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-xs text-gray-800">
{primary}
<span className="text-gray-400">
{" "}
· {entry.email}
</span>
</p>
</div>
<button
type="button"
onClick={() =>
handleRemoveShareUser(
entry.email,
)
}
className="self-center inline-flex items-center rounded-full px-2 py-1 text-xs text-gray-500 transition-colors hover:text-red-600"
aria-label={`Remove ${entry.email}`}
>
<X className="h-3 w-3" />
</button>
</li>
);
})}
</ul>
)}
</div>
</div>
) : (
<div className="flex min-h-0 flex-1 flex-col">
<FileDirectory
selectedDocuments={selectedDocuments}
onChange={setSelectedDocuments}
showTabs
/>
</div>
)}
{error && (
<p className="mt-3 text-sm text-red-500">{error}</p>
)}
</form>
</Modal>
);
}