Yimaru-Admin/src/pages/ProfilePage.tsx

434 lines
19 KiB
TypeScript

import { useEffect, useState, type ChangeEvent } from "react";
import { BadgeCheck, Briefcase, CalendarDays, Mail, Phone, Shield, User } from "lucide-react";
import { Badge } from "../components/ui/badge";
import { Button } from "../components/ui/button";
import { Card, CardContent } from "../components/ui/card";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../components/ui/dialog";
import { Input } from "../components/ui/input";
import { Textarea } from "../components/ui/textarea";
import { FileUpload } from "../components/ui/file-upload";
import { getMyProfile } from "../api/users.api";
import { updateTeamMember } from "../api/team.api";
import { uploadImageFile } from "../api/files.api";
import { SpinnerIcon } from "../components/ui/spinner-icon";
import type { UpdateTeamMemberRequest } from "../types/team.types";
import { toast } from "sonner";
function formatDate(dateStr: string | null | undefined): string {
if (!dateStr) return "—";
return new Date(dateStr).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
}
function formatDateTime(dateStr: string | null | undefined): string {
if (!dateStr) return "—";
return new Date(dateStr).toLocaleString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
interface TeamMeProfile {
id: number
first_name: string
last_name: string
email: string
phone_number: string
team_role: string
department: string
job_title: string
employment_type: string
hire_date: string
bio: string
status: string
email_verified: boolean
permissions: string[]
last_login: string | null
created_at: string
emergency_contact?: string
work_phone?: string
profile_picture_url?: string
}
function LoadingSkeleton() {
return (
<div className="w-full space-y-8 py-10">
<div className="animate-pulse space-y-8">
<div className="overflow-hidden rounded-2xl border border-grayScale-100">
<div className="h-40 bg-gradient-to-r from-grayScale-100 via-grayScale-200/60 to-grayScale-100" />
<div className="flex flex-col items-center px-8 pb-8">
<div className="-mt-14 h-28 w-28 rounded-full bg-grayScale-100 ring-4 ring-white" />
<div className="mt-4 h-6 w-48 rounded-lg bg-grayScale-100" />
<div className="mt-3 h-5 w-24 rounded-full bg-grayScale-100" />
<div className="mt-5 flex gap-3">
<div className="h-7 w-20 rounded-full bg-grayScale-100" />
<div className="h-7 w-28 rounded-full bg-grayScale-100" />
<div className="h-7 w-28 rounded-full bg-grayScale-100" />
</div>
</div>
</div>
<div className="grid gap-6 md:grid-cols-3">
{[1, 2, 3].map((i) => (
<div key={i} className="rounded-2xl border border-grayScale-100 p-6">
<div className="mb-5 h-5 w-40 rounded bg-grayScale-100" />
<div className="space-y-4">
{[1, 2, 3, 4].map((j) => (
<div key={j} className="flex items-center justify-between">
<div className="h-4 w-20 rounded bg-grayScale-100" />
<div className="h-4 w-28 rounded bg-grayScale-100" />
</div>
))}
</div>
</div>
))}
</div>
</div>
</div>
);
}
export function ProfilePage() {
const [profile, setProfile] = useState<TeamMeProfile | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [editing, setEditing] = useState(false);
const [saving, setSaving] = useState(false);
const [profilePictureFile, setProfilePictureFile] = useState<File | null>(null);
const [editForm, setEditForm] = useState<UpdateTeamMemberRequest>({
first_name: "",
last_name: "",
phone_number: "",
profile_picture_url: "",
bio: "",
});
useEffect(() => {
const fetchProfile = async () => {
try {
const res = await getMyProfile();
setProfile((res.data?.data ?? null) as unknown as TeamMeProfile | null);
} catch (err) {
console.error("Failed to fetch profile", err);
setError("Failed to load profile. Please try again later.");
} finally {
setLoading(false);
}
};
fetchProfile();
}, []);
const startEditing = () => {
if (!profile) return;
const nextForm: UpdateTeamMemberRequest = {
first_name: profile.first_name ?? "",
last_name: profile.last_name ?? "",
phone_number: profile.phone_number ?? "",
profile_picture_url: profile.profile_picture_url ?? "",
bio: profile.bio ?? "",
};
setEditForm(nextForm);
setProfilePictureFile(null);
setEditing(true);
};
const cancelEditing = () => {
setProfilePictureFile(null);
setEditing(false);
};
const updateField = (field: keyof UpdateTeamMemberRequest, value: string) => {
setEditForm((prev) => ({ ...prev, [field]: value }));
};
const handleSave = async () => {
if (!profile) return;
let nextProfilePictureUrl = editForm.profile_picture_url ?? "";
if (profilePictureFile) {
try {
const uploadRes = await uploadImageFile(profilePictureFile);
const uploadedUrl = uploadRes.data?.data?.url?.trim();
if (!uploadedUrl) throw new Error("Missing uploaded image url");
nextProfilePictureUrl = uploadedUrl;
} catch (err) {
console.error("Failed to upload profile picture:", err);
toast.error("Failed to upload profile picture");
return;
}
}
const payload: UpdateTeamMemberRequest = {
bio: editForm.bio ?? "",
first_name: editForm.first_name ?? "",
last_name: editForm.last_name ?? "",
phone_number: editForm.phone_number ?? "",
profile_picture_url: nextProfilePictureUrl,
};
setSaving(true);
try {
await updateTeamMember(profile.id, payload);
const refreshed = await getMyProfile();
setProfile((refreshed.data?.data ?? null) as unknown as TeamMeProfile | null);
setEditing(false);
setProfilePictureFile(null);
toast.success("Profile updated successfully");
} catch (err) {
console.error("Failed to update team member profile", err);
toast.error("Failed to update profile");
} finally {
setSaving(false);
}
};
if (loading) return <LoadingSkeleton />;
if (error || !profile) {
return (
<div className="w-full py-16">
<Card className="border-dashed">
<CardContent className="flex flex-col items-center gap-5 p-12">
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-grayScale-100">
<User className="h-10 w-10 text-grayScale-300" />
</div>
<div className="text-center">
<p className="text-lg font-semibold tracking-tight text-grayScale-600">
{error || "Profile not available"}
</p>
<p className="mt-1 text-sm text-grayScale-400">
Please check your connection and try again.
</p>
</div>
</CardContent>
</Card>
</div>
);
}
const fullName = `${profile.first_name} ${profile.last_name}`;
const initials = `${profile.first_name?.[0] ?? ""}${profile.last_name?.[0] ?? ""}`.toUpperCase();
return (
<div className="mx-auto w-full max-w-7xl rounded-2xl bg-[#f7f1f8] p-4 pb-8 sm:p-6">
<div className="overflow-hidden rounded-2xl border border-[#d9bddb] bg-white">
<div className="h-40 w-full bg-gradient-to-r from-[#d6aed6] via-[#e4cce4] to-[#cba0cd]" />
<div className="grid gap-0 lg:grid-cols-[280px_minmax(0,1fr)]">
<aside className="border-r border-[#eadbea] bg-white px-5 pb-6">
<div className="-mt-16">
<div className="flex h-28 w-28 items-center justify-center rounded-full border-4 border-white bg-[#d6aed6] text-2xl font-bold text-[#6f2aa8]">
{initials}
</div>
<h2 className="mt-3 text-2xl font-bold text-grayScale-700">{fullName}</h2>
<p className="text-sm text-grayScale-400">{profile.job_title || "Team Member"}</p>
</div>
<div className="mt-4">
<div className="flex w-full items-center justify-between rounded-lg border border-[#d9bddb] bg-[#f4e8f4] px-3 py-2">
<span className="text-sm font-medium text-[#6f2aa8]">Account Status</span>
<span className="text-sm font-semibold uppercase text-[#5e2390]">{profile.status}</span>
</div>
</div>
<div className="mt-5 space-y-5 text-sm">
<section>
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-grayScale-400">About</p>
<div className="space-y-2 text-grayScale-600">
<div className="flex items-center gap-2"><Briefcase className="h-4 w-4 text-[#6f2aa8]" />{profile.job_title || "Job title not set"}</div>
<div className="flex items-center gap-2"><Shield className="h-4 w-4 text-[#6f2aa8]" />{profile.team_role || "Role not set"}</div>
<div className="flex items-center gap-2"><CalendarDays className="h-4 w-4 text-[#6f2aa8]" />Hire date: {formatDate(profile.hire_date)}</div>
<div className="flex items-center gap-2"><BadgeCheck className="h-4 w-4 text-[#6f2aa8]" />{profile.department || "Department not set"}</div>
</div>
</section>
<section>
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-grayScale-400">Contact</p>
<div className="space-y-2 text-grayScale-600">
<div className="flex items-center gap-2"><Mail className="h-4 w-4 text-[#6f2aa8]" />{profile.email}</div>
<div className="flex items-center gap-2"><Phone className="h-4 w-4 text-[#6f2aa8]" />{profile.phone_number || "—"}</div>
</div>
</section>
<section>
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-grayScale-400">Access</p>
<p className="text-xs text-grayScale-500">Permissions from `/team/me`</p>
<div className="mt-2 flex flex-wrap gap-1.5">
{(profile.permissions ?? []).length === 0 ? (
<Badge className="bg-[#ecd9ec] text-[#6f2aa8]">No permissions listed</Badge>
) : (
profile.permissions.map((permission) => (
<Badge key={permission} className="bg-[#ecd9ec] text-[#6f2aa8]">
{permission}
</Badge>
))
)}
</div>
</section>
</div>
</aside>
<main className="bg-[#fdf8fd] px-5 py-6 sm:px-7">
<div className="space-y-5">
<Card className="border-[#d9bddb] bg-white shadow-none">
<CardContent className="p-5">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-base font-semibold text-grayScale-700">Summary</h3>
<button
type="button"
onClick={startEditing}
className="inline-flex items-center rounded-md border border-[#d9bddb] bg-[#f4e8f4] px-3 py-1.5 text-sm font-medium text-[#6f2aa8] transition-colors hover:bg-[#ecd9ec] hover:text-[#5e2390] focus:outline-none focus-visible:ring-2 focus-visible:ring-[#c39bd4]"
>
Edit profile
</button>
</div>
<div className="space-y-2 text-sm text-grayScale-600">
<div className="flex items-center gap-2"><Briefcase className="h-4 w-4 text-[#6f2aa8]" />{profile.job_title || "Role-focused work item"}</div>
<div className="flex items-center gap-2"><Shield className="h-4 w-4 text-[#6f2aa8]" />{profile.team_role || "Team responsibility"}</div>
<div className="flex items-center gap-2"><Mail className="h-4 w-4 text-[#6f2aa8]" />{profile.email}</div>
<div className="flex items-center gap-2"><Phone className="h-4 w-4 text-[#6f2aa8]" />{profile.phone_number || "No phone number"}</div>
</div>
</CardContent>
</Card>
<Card className="border-[#d9bddb] bg-white shadow-none">
<CardContent className="p-5">
<h3 className="mb-3 text-base font-semibold text-grayScale-700">Employment</h3>
<div className="rounded-lg border border-[#dcc3df] bg-[#f4e8f4] p-3">
<p className="text-sm font-medium text-grayScale-700">{profile.department || "Department not set"}</p>
<p className="text-xs text-grayScale-500">Employment type: {profile.employment_type || "—"}</p>
</div>
</CardContent>
</Card>
<Card className="border-[#d9bddb] bg-white shadow-none">
<CardContent className="p-5">
<h3 className="mb-3 text-base font-semibold text-grayScale-700">More about me</h3>
<div className="flex flex-wrap gap-2">
<Badge className="bg-[#ecd9ec] text-[#6f2aa8]">Status {profile.status}</Badge>
<Badge className="bg-[#ecd9ec] text-[#6f2aa8]">Email {profile.email_verified ? "verified" : "not verified"}</Badge>
<Badge className="bg-[#ecd9ec] text-[#6f2aa8]">Joined {formatDate(profile.created_at)}</Badge>
<Badge className="bg-[#ecd9ec] text-[#6f2aa8]">Last login {formatDateTime(profile.last_login)}</Badge>
</div>
</CardContent>
</Card>
<Card className="border-[#d9bddb] bg-white shadow-none">
<CardContent className="grid gap-3 p-5 sm:grid-cols-2">
<div>
<p className="mb-1 text-xs font-medium text-grayScale-500">First Name</p>
<p className="text-sm font-medium text-grayScale-700">{profile.first_name}</p>
</div>
<div>
<p className="mb-1 text-xs font-medium text-grayScale-500">Last Name</p>
<p className="text-sm font-medium text-grayScale-700">{profile.last_name}</p>
</div>
<div>
<p className="mb-1 text-xs font-medium text-grayScale-500">Department</p>
<p className="text-sm font-medium text-grayScale-700">{profile.department || "—"}</p>
</div>
<div>
<p className="mb-1 text-xs font-medium text-grayScale-500">Team Role</p>
<p className="text-sm font-medium text-grayScale-700">{profile.team_role || "—"}</p>
</div>
<div>
<p className="mb-1 text-xs font-medium text-grayScale-500">Job Title</p>
<p className="text-sm font-medium text-grayScale-700">{profile.job_title || "—"}</p>
</div>
<div>
<p className="mb-1 text-xs font-medium text-grayScale-500">Hire Date</p>
<p className="text-sm font-medium text-grayScale-700">{formatDate(profile.hire_date) || "—"}</p>
</div>
<div>
<p className="mb-1 text-xs font-medium text-grayScale-500">Phone Number</p>
<p className="text-sm font-medium text-grayScale-700">{profile.phone_number || "—"}</p>
</div>
</CardContent>
</Card>
</div>
</main>
</div>
</div>
<Dialog open={editing} onOpenChange={(open) => !saving && setEditing(open)}>
<DialogContent className="max-h-[88vh] overflow-y-auto border-[#d9bddb] bg-[#fdf8fd] sm:max-w-3xl">
<DialogHeader>
<DialogTitle className="text-[#6f2aa8]">Edit profile</DialogTitle>
</DialogHeader>
<div className="grid gap-3 sm:grid-cols-2">
<div>
<p className="mb-1 text-xs font-medium text-grayScale-500">First Name</p>
<Input value={editForm.first_name ?? ""} onChange={(e) => updateField("first_name", e.target.value)} />
</div>
<div>
<p className="mb-1 text-xs font-medium text-grayScale-500">Last Name</p>
<Input value={editForm.last_name ?? ""} onChange={(e) => updateField("last_name", e.target.value)} />
</div>
<div>
<p className="mb-1 text-xs font-medium text-grayScale-500">Phone Number</p>
<Input value={editForm.phone_number ?? ""} onChange={(e) => updateField("phone_number", e.target.value)} />
</div>
<div>
<p className="mb-1 text-xs font-medium text-grayScale-500">Profile Picture</p>
<div className="space-y-2">
<FileUpload
accept="image/*"
onFileSelect={setProfilePictureFile}
label="Upload profile picture"
description="JPEG, PNG, WEBP"
className="min-h-[90px] rounded-lg border-2 border-dashed border-grayScale-200 transition-colors hover:border-brand-400 hover:bg-brand-50/30"
/>
<Input
value={editForm.profile_picture_url ?? ""}
onChange={(e) => updateField("profile_picture_url", e.target.value)}
placeholder="Or paste image URL (https://...)"
/>
</div>
</div>
<div className="sm:col-span-2">
<p className="mb-1 text-xs font-medium text-grayScale-500">Bio</p>
<Textarea
value={editForm.bio ?? ""}
onChange={(e: ChangeEvent<HTMLTextAreaElement>) => updateField("bio", e.target.value)}
rows={4}
/>
</div>
</div>
<DialogFooter className="mt-2">
<Button
type="button"
variant="outline"
className="border-[#d9bddb] text-[#6f2aa8]"
onClick={cancelEditing}
disabled={saving}
>
Cancel
</Button>
<Button
type="button"
className="bg-[#6f2aa8] text-white hover:bg-[#5e2390]"
onClick={handleSave}
disabled={saving}
>
{saving ? <SpinnerIcon className="mr-1 h-4 w-4" /> : null}
{saving ? "Saving..." : "Save changes"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}