Add phone and birthday fields to User model with admin editor

- Add phone/birthday columns via Prisma migration
- Admin user page shows phone and birthday with inline edit UI
- UserContactEditor client component for editing contact info
- updateUserContact server action with admin-only guard

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-27 16:43:35 +05:00
parent c64f393a7b
commit fdb9f96382
5 changed files with 149 additions and 1 deletions
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "phone" TEXT;
ALTER TABLE "User" ADD COLUMN "birthday" TIMESTAMP(3);
+2
View File
@@ -18,6 +18,8 @@ model User {
emailVerified Boolean @default(false) emailVerified Boolean @default(false)
image String? image String?
role String @default("student") // student | curator | admin role String @default("student") // student | curator | admin
phone String?
birthday DateTime?
banned Boolean? @default(false) banned Boolean? @default(false)
banReason String? banReason String?
banExpires DateTime? banExpires DateTime?
+15
View File
@@ -41,6 +41,21 @@ export async function bulkGrantAccess(
revalidatePath(`/admin/users/${userId}`); revalidatePath(`/admin/users/${userId}`);
} }
export async function updateUserContact(
userId: string,
data: { phone: string; birthday: string }
) {
await requireAdmin();
await prisma.user.update({
where: { id: userId },
data: {
phone: data.phone.trim() || null,
birthday: data.birthday ? new Date(data.birthday) : null,
},
});
revalidatePath(`/admin/users/${userId}`);
}
export async function revokeUserAccess(userId: string, courseId: string) { export async function revokeUserAccess(userId: string, courseId: string) {
const session = await requireAdmin(); const session = await requireAdmin();
await prisma.courseEnrollment.delete({ await prisma.courseEnrollment.delete({
+9 -1
View File
@@ -2,6 +2,7 @@ import { prisma } from "@/lib/prisma";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { UserEnrollmentManager } from "@/components/admin/user-enrollment-manager"; import { UserEnrollmentManager } from "@/components/admin/user-enrollment-manager";
import { UserContactEditor } from "@/components/admin/user-contact-editor";
interface Props { interface Props {
params: Promise<{ userId: string }>; params: Promise<{ userId: string }>;
@@ -47,7 +48,7 @@ export default async function UserPage({ params }: Props) {
</nav> </nav>
{/* User info */} {/* User info */}
<section className="card-aubade p-6 mb-6"> <section className="card-aubade p-6 mb-6 space-y-4">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div> <div>
<h1 className="text-xl font-bold">{user.name}</h1> <h1 className="text-xl font-bold">{user.name}</h1>
@@ -60,6 +61,13 @@ export default async function UserPage({ params }: Props) {
</span> </span>
</div> </div>
</div> </div>
<div style={{ borderTop: "2px solid var(--border)", paddingTop: "1rem" }}>
<UserContactEditor
userId={userId}
phone={user.phone ?? null}
birthday={user.birthday ?? null}
/>
</div>
</section> </section>
{/* Enrollments + bulk grant */} {/* Enrollments + bulk grant */}
@@ -0,0 +1,120 @@
"use client";
import { useState, useTransition } from "react";
import { updateUserContact } from "@/app/admin/users/[userId]/actions";
const inputStyle = {
border: "2px solid var(--border)",
background: "var(--background)",
outline: "none",
width: "100%",
padding: "0.4rem 0.6rem",
fontSize: "0.875rem",
fontFamily: "inherit",
} as React.CSSProperties;
interface Props {
userId: string;
phone: string | null;
birthday: Date | null;
}
export function UserContactEditor({ userId, phone, birthday }: Props) {
const [editing, setEditing] = useState(false);
const [phoneVal, setPhoneVal] = useState(phone ?? "");
const [birthdayVal, setBirthdayVal] = useState(
birthday ? birthday.toISOString().slice(0, 10) : ""
);
const [pending, startTransition] = useTransition();
function handleSave() {
startTransition(async () => {
await updateUserContact(userId, { phone: phoneVal, birthday: birthdayVal });
setEditing(false);
});
}
if (!editing) {
return (
<div className="flex items-start gap-6 flex-wrap">
<div className="space-y-0.5">
<p className="text-xs uppercase tracking-widest font-bold" style={{ color: "var(--muted-foreground)" }}>
Телефон
</p>
<p className="text-sm">{phone || "—"}</p>
</div>
<div className="space-y-0.5">
<p className="text-xs uppercase tracking-widest font-bold" style={{ color: "var(--muted-foreground)" }}>
День рождения
</p>
<p className="text-sm">
{birthday
? new Date(birthday).toLocaleDateString("ru-RU", { day: "numeric", month: "long", year: "numeric" })
: "—"}
</p>
</div>
<button
type="button"
onClick={() => setEditing(true)}
className="text-xs underline self-end pb-0.5"
style={{ color: "var(--muted-foreground)" }}
>
Изменить
</button>
</div>
);
}
return (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<label className="text-xs uppercase tracking-widest font-bold" style={{ color: "var(--muted-foreground)" }}>
Телефон
</label>
<input
type="tel"
value={phoneVal}
onChange={(e) => setPhoneVal(e.target.value)}
placeholder="+7 900 000-00-00"
style={inputStyle}
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
/>
</div>
<div className="space-y-1">
<label className="text-xs uppercase tracking-widest font-bold" style={{ color: "var(--muted-foreground)" }}>
День рождения
</label>
<input
type="date"
value={birthdayVal}
onChange={(e) => setBirthdayVal(e.target.value)}
style={inputStyle}
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
/>
</div>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={handleSave}
disabled={pending}
className="btn-aubade btn-aubade-accent px-4 py-1.5 text-xs"
style={{ opacity: pending ? 0.6 : 1 }}
>
{pending ? "Сохранение..." : "Сохранить"}
</button>
<button
type="button"
onClick={() => setEditing(false)}
className="text-xs underline"
style={{ color: "var(--muted-foreground)" }}
>
Отмена
</button>
</div>
</div>
);
}