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:
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "phone" TEXT;
|
||||
ALTER TABLE "User" ADD COLUMN "birthday" TIMESTAMP(3);
|
||||
@@ -18,6 +18,8 @@ model User {
|
||||
emailVerified Boolean @default(false)
|
||||
image String?
|
||||
role String @default("student") // student | curator | admin
|
||||
phone String?
|
||||
birthday DateTime?
|
||||
banned Boolean? @default(false)
|
||||
banReason String?
|
||||
banExpires DateTime?
|
||||
|
||||
@@ -41,6 +41,21 @@ export async function bulkGrantAccess(
|
||||
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) {
|
||||
const session = await requireAdmin();
|
||||
await prisma.courseEnrollment.delete({
|
||||
|
||||
@@ -2,6 +2,7 @@ import { prisma } from "@/lib/prisma";
|
||||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { UserEnrollmentManager } from "@/components/admin/user-enrollment-manager";
|
||||
import { UserContactEditor } from "@/components/admin/user-contact-editor";
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ userId: string }>;
|
||||
@@ -47,7 +48,7 @@ export default async function UserPage({ params }: Props) {
|
||||
</nav>
|
||||
|
||||
{/* 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>
|
||||
<h1 className="text-xl font-bold">{user.name}</h1>
|
||||
@@ -60,6 +61,13 @@ export default async function UserPage({ params }: Props) {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ borderTop: "2px solid var(--border)", paddingTop: "1rem" }}>
|
||||
<UserContactEditor
|
||||
userId={userId}
|
||||
phone={user.phone ?? null}
|
||||
birthday={user.birthday ?? null}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user