This commit is contained in:
2025-12-09 17:02:27 +09:00
parent 26f8e1dab2
commit 83127da569
275 changed files with 139682 additions and 1 deletions

View File

@@ -0,0 +1,221 @@
"use client"
import { useState } from "react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { authApi } from "@/lib/api/auth.api"
import { toast } from "sonner"
import { useRouter } from "next/navigation"
export function FindIdForm({
className,
...props
}: React.ComponentProps<"form">) {
const router = useRouter()
const [step, setStep] = useState<"email" | "verify" | "result">("email")
const [userName, setUserName] = useState("")
const [userEmail, setUserEmail] = useState("")
const [code, setCode] = useState("")
const [foundUserId, setFoundUserId] = useState("")
const [isLoading, setIsLoading] = useState(false)
const [timer, setTimer] = useState(0)
// 인증번호 발송
const handleSendCode = async (e: React.FormEvent) => {
e.preventDefault()
console.log(' [아이디 찾기] 인증번호 발송 시도')
console.log(' - userName:', userName)
console.log(' - userEmail:', userEmail)
if (!userName || !userEmail) {
toast.error("이름과 이메일을 모두 입력해주세요")
return
}
setIsLoading(true)
try {
console.log(' API 호출:', { userName, userEmail })
const result = await authApi.sendFindIdCode(userName, userEmail)
console.log(' API 응답:', result)
toast.success(result.message)
setStep("verify")
setTimer(result.expiresIn) // (180초 = 3분) 제한 시간 설정========================
// 타이머 시작
const interval = setInterval(() => {
setTimer((prev) => {
if (prev <= 1) {
clearInterval(interval)
return 0
}
return prev - 1
})
}, 1000)
} catch (error: any) {
console.error(' API 에러:', error)
toast.error(error.message || "인증번호 발송에 실패했습니다")
} finally {
setIsLoading(false)
}
}
// 인증번호 확인
const handleVerifyCode = async (e: React.FormEvent) => {
e.preventDefault()
if (!code) {
toast.error("인증번호를 입력해주세요")
return
}
setIsLoading(true)
try {
const result = await authApi.verifyFindIdCode(userEmail, code)
toast.success(result.message)
setFoundUserId(result.maskedUserId || result.userId)
setStep("result")
} catch (error: any) {
toast.error(error.message || "인증번호 확인에 실패했습니다")
} finally {
setIsLoading(false)
}
}
// 로그인 페이지로 이동
const handleGoToLogin = () => {
router.push("/login")
}
// 타이머 포맷 (mm:ss)
const formatTime = (seconds: number) => {
const m = Math.floor(seconds / 60)
const s = seconds % 60
return `${m}:${s.toString().padStart(2, "0")}`
}
return (
<form className={cn("flex flex-col gap-6", className)} {...props}>
<FieldGroup>
<div className="flex flex-col items-center gap-1 text-center">
<h1 className="text-2xl font-bold"> </h1>
<p className="text-muted-foreground text-sm text-balance">
{step === "email" && "가입 시 등록한 이름과 이메일을 입력해주세요"}
{step === "verify" && "이메일로 전송된 인증번호를 입력해주세요"}
{step === "result" && "아이디 찾기가 완료되었습니다"}
</p>
</div>
{step === "email" && (
<>
<Field>
<FieldLabel htmlFor="name"></FieldLabel>
<Input
id="name"
type="text"
placeholder="홍길동"
value={userName}
onChange={(e) => setUserName(e.target.value)}
required
disabled={isLoading}
/>
</Field>
<Field>
<FieldLabel htmlFor="email"></FieldLabel>
<Input
id="email"
type="email"
placeholder="example@email.com"
value={userEmail}
onChange={(e) => setUserEmail(e.target.value)}
required
disabled={isLoading}
/>
<FieldDescription>
</FieldDescription>
</Field>
<Field>
<Button type="submit" onClick={handleSendCode} disabled={isLoading}>
{isLoading ? "발송 중..." : "인증번호 발송"}
</Button>
</Field>
</>
)}
{step === "verify" && (
<>
<Field>
<FieldLabel htmlFor="email"></FieldLabel>
<Input
id="email"
type="email"
value={userEmail}
disabled
/>
</Field>
<Field>
<FieldLabel htmlFor="code"></FieldLabel>
<Input
id="code"
type="text"
placeholder="6자리 인증번호"
value={code}
onChange={(e) => setCode(e.target.value)}
maxLength={6}
required
disabled={isLoading}
/>
<FieldDescription>
{timer > 0 ? `남은 시간: ${formatTime(timer)}` : "인증번호가 만료되었습니다"}
</FieldDescription>
</Field>
<Field>
<Button type="submit" onClick={handleVerifyCode} disabled={isLoading || timer === 0}>
{isLoading ? "확인 중..." : "인증번호 확인"}
</Button>
</Field>
<Field>
<Button type="button" variant="outline" onClick={() => setStep("email")} disabled={isLoading}>
</Button>
</Field>
</>
)}
{step === "result" && (
<>
<Field>
<div className="bg-muted rounded-lg p-6 text-center">
<p className="text-sm text-muted-foreground mb-2"> </p>
<p className="text-2xl font-bold text-primary">{foundUserId}</p>
<p className="text-sm text-muted-foreground mt-2"></p>
</div>
</Field>
<Field>
<Button type="button" onClick={handleGoToLogin}>
</Button>
</Field>
<Field>
<Button type="button" variant="outline" onClick={() => router.push("/findpw")}>
</Button>
</Field>
</>
)}
<Field>
<FieldDescription className="px-6 text-center">
? <a href="/register" className="underline"> </a>
</FieldDescription>
</Field>
</FieldGroup>
</form>
)
}

View File

@@ -0,0 +1,213 @@
"use client"
import { useState } from "react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
FieldSeparator,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { authApi } from "@/lib/api/auth.api"
import { toast } from "sonner"
import { useRouter } from "next/navigation"
export function FindIdForm({
className,
...props
}: React.ComponentProps<"form">) {
const router = useRouter()
const [step, setStep] = useState<"email" | "verify" | "result">("email")
const [userName, setUserName] = useState("")
const [userEmail, setUserEmail] = useState("")
const [code, setCode] = useState("")
const [foundUserId, setFoundUserId] = useState("")
const [isLoading, setIsLoading] = useState(false)
const [timer, setTimer] = useState(0)
const handleSendCode = async (e: React.FormEvent) => {
e.preventDefault()
if (!userName || !userEmail) {
toast.error("이름과 이메일을 모두 입력해주세요")
return
}
setIsLoading(true)
try {
const result = await authApi.sendFindIdCode(userName, userEmail)
toast.success(result.message)
setStep("verify")
setTimer(result.expiresIn)
const interval = setInterval(() => {
setTimer((prev) => {
if (prev <= 1) {
clearInterval(interval)
return 0
}
return prev - 1
})
}, 1000)
} catch (error: any) {
toast.error(error.message || "인증번호 발송에 실패했습니다")
} finally {
setIsLoading(false)
}
}
const handleVerifyCode = async (e: React.FormEvent) => {
e.preventDefault()
if (!code) {
toast.error("인증번호를 입력해주세요")
return
}
setIsLoading(true)
try {
const result = await authApi.verifyFindIdCode(userEmail, code)
toast.success(result.message)
setFoundUserId(result.maskedUserId || result.userId)
setStep("result")
} catch (error: any) {
toast.error(error.message || "인증번호 확인에 실패했습니다")
} finally {
setIsLoading(false)
}
}
const formatTime = (seconds: number) => {
const m = Math.floor(seconds / 60)
const s = seconds % 60
return m + ":" + String(s).padStart(2, "0")
}
return (
<form className={cn("flex flex-col", className)} {...props}>
<FieldGroup>
<div className="flex flex-col items-center gap-1 text-center">
<h1 className="text-2xl font-bold"> </h1>
<p className="text-muted-foreground text-sm text-balance">
{step === "email" && "가입 시 등록한 이름과 이메일을 입력해주세요"}
{step === "verify" && "이메일로 전송된 인증번호를 입력해주세요"}
{step === "result" && "아이디 찾기가 완료되었습니다"}
</p>
</div>
{step === "email" && (
<>
<Field>
<FieldLabel htmlFor="name"></FieldLabel>
<Input
id="name"
type="text"
placeholder="홍길동"
value={userName}
onChange={(e) => setUserName(e.target.value)}
required
disabled={isLoading}
/>
</Field>
<Field>
<FieldLabel htmlFor="email"></FieldLabel>
<Input
id="email"
type="email"
placeholder="example@email.com"
value={userEmail}
onChange={(e) => setUserEmail(e.target.value)}
required
disabled={isLoading}
/>
<FieldDescription>
</FieldDescription>
</Field>
<Field>
<Button type="submit" onClick={handleSendCode} disabled={isLoading}>
{isLoading ? "발송 중..." : "인증번호 발송"}
</Button>
</Field>
</>
)}
{step === "verify" && (
<>
<Field>
<FieldLabel htmlFor="email"></FieldLabel>
<Input
id="email"
type="email"
value={userEmail}
disabled
/>
</Field>
<Field>
<FieldLabel htmlFor="code"></FieldLabel>
<Input
id="code"
type="text"
placeholder="6자리 인증번호"
value={code}
onChange={(e) => setCode(e.target.value)}
maxLength={6}
required
disabled={isLoading}
/>
<FieldDescription>
{timer > 0 ? "남은 시간: " + formatTime(timer) : "인증번호가 만료되었습니다"}
</FieldDescription>
</Field>
<Field>
<Button type="submit" onClick={handleVerifyCode} disabled={isLoading || timer === 0}>
{isLoading ? "확인 중..." : "인증번호 확인"}
</Button>
</Field>
<Field>
<Button type="button" variant="outline" onClick={() => setStep("email")} disabled={isLoading}>
</Button>
</Field>
</>
)}
{step === "result" && (
<>
<Field>
<div className="bg-muted rounded-lg p-6 text-center">
<p className="text-sm text-muted-foreground mb-2"> </p>
<p className="text-2xl font-bold text-primary">{foundUserId}</p>
<p className="text-sm text-muted-foreground mt-2"></p>
</div>
</Field>
<Field>
<Button type="button" onClick={() => router.push("/login")}>
</Button>
</Field>
<Field>
<Button type="button" variant="outline" onClick={() => router.push("/findpw")}>
</Button>
</Field>
</>
)}
<Field className="-mt-5">
<Button variant="outline" type="button" onClick={() => router.push("/login")} className="w-full border-2 border-primary text-primary hover:bg-primary hover:text-primary-foreground hover:border-transparent transition-all duration-300">
</Button>
</Field>
<FieldSeparator> ?</FieldSeparator>
<Field>
<Button variant="outline" type="button" onClick={() => router.push("/signup")} className="w-full border-2 border-primary text-primary hover:bg-primary hover:text-primary-foreground hover:border-transparent transition-all duration-300">
</Button>
</Field>
</FieldGroup>
</form>
)
}

View File

@@ -0,0 +1,323 @@
"use client"
import { useState } from "react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
FieldSeparator,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { authApi } from "@/lib/api/auth.api"
import { toast } from "sonner"
import { useRouter } from "next/navigation"
import { Eye, EyeOff } from "lucide-react"
export function FindPwForm({
className,
...props
}: React.ComponentProps<"form">) {
const router = useRouter()
const [step, setStep] = useState<"info" | "verify" | "reset" | "complete">("info")
const [userId, setUserId] = useState("")
const [userEmail, setUserEmail] = useState("")
const [code, setCode] = useState("")
const [resetToken, setResetToken] = useState("")
const [newPassword, setNewPassword] = useState("")
const [confirmPassword, setConfirmPassword] = useState("")
const [showPassword, setShowPassword] = useState(false)
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [timer, setTimer] = useState(0)
// 인증번호 발송
const handleSendCode = async (e: React.FormEvent) => {
e.preventDefault()
if (!userId || !userEmail) {
toast.error("아이디와 이메일을 모두 입력해주세요")
return
}
setIsLoading(true)
try {
const result = await authApi.sendResetPasswordCode(userId, userEmail)
toast.success(result.message)
setStep("verify")
setTimer(result.expiresIn) // 이미 초 단위로 전달됨 (180초 = 3분)
// 타이머 시작
const interval = setInterval(() => {
setTimer((prev) => {
if (prev <= 1) {
clearInterval(interval)
return 0
}
return prev - 1
})
}, 1000)
} catch (error: any) {
toast.error(error.message || "인증번호 발송에 실패했습니다")
} finally {
setIsLoading(false)
}
}
// 인증번호 확인
const handleVerifyCode = async (e: React.FormEvent) => {
e.preventDefault()
if (!code) {
toast.error("인증번호를 입력해주세요")
return
}
setIsLoading(true)
try {
const result = await authApi.verifyResetPasswordCode(userId, userEmail, code)
toast.success(result.message)
setResetToken(result.resetToken)
setStep("reset")
} catch (error: any) {
toast.error(error.message || "인증번호 확인에 실패했습니다")
} finally {
setIsLoading(false)
}
}
// 비밀번호 재설정
const handleResetPassword = async (e: React.FormEvent) => {
e.preventDefault()
if (!newPassword || !confirmPassword) {
toast.error("비밀번호를 입력해주세요")
return
}
if (newPassword !== confirmPassword) {
toast.error("비밀번호가 일치하지 않습니다")
return
}
if (newPassword.length < 8) {
toast.error("비밀번호는 8자 이상이어야 합니다")
return
}
setIsLoading(true)
try {
const result = await authApi.resetPassword(resetToken, newPassword)
toast.success(result.message)
setStep("complete")
} catch (error: any) {
toast.error(error.message || "비밀번호 재설정에 실패했습니다")
} finally {
setIsLoading(false)
}
}
// 로그인 페이지로 이동
const handleGoToLogin = () => {
router.push("/login")
}
// 타이머 포맷 (mm:ss)
const formatTime = (seconds: number) => {
const m = Math.floor(seconds / 60)
const s = seconds % 60
return `${m}:${s.toString().padStart(2, "0")}`
}
return (
<form className={cn("flex flex-col", className)} {...props}>
<FieldGroup>
<div className="flex flex-col items-center gap-1 text-center">
<h1 className="text-2xl font-bold"> </h1>
<p className="text-muted-foreground text-sm text-balance">
{step === "info" && "아이디와 이메일을 입력해주세요"}
{step === "verify" && "이메일로 전송된 인증번호를 입력해주세요"}
{step === "reset" && "새로운 비밀번호를 설정해주세요"}
{step === "complete" && "비밀번호 재설정이 완료되었습니다"}
</p>
</div>
{step === "info" && (
<>
<Field>
<FieldLabel htmlFor="userId"></FieldLabel>
<Input
id="userId"
type="text"
placeholder="아이디를 입력하세요"
value={userId}
onChange={(e) => setUserId(e.target.value)}
required
disabled={isLoading}
/>
</Field>
<Field>
<FieldLabel htmlFor="email"></FieldLabel>
<Input
id="email"
type="email"
placeholder="example@email.com"
value={userEmail}
onChange={(e) => setUserEmail(e.target.value)}
required
disabled={isLoading}
/>
<FieldDescription>
</FieldDescription>
</Field>
<Field>
<Button type="submit" onClick={handleSendCode} disabled={isLoading}>
{isLoading ? "발송 중..." : "인증번호 발송"}
</Button>
</Field>
</>
)}
{step === "verify" && (
<>
<Field>
<FieldLabel htmlFor="userId"></FieldLabel>
<Input
id="userId"
type="text"
value={userId}
disabled
/>
</Field>
<Field>
<FieldLabel htmlFor="email"></FieldLabel>
<Input
id="email"
type="email"
value={userEmail}
disabled
/>
</Field>
<Field>
<FieldLabel htmlFor="code"></FieldLabel>
<Input
id="code"
type="text"
placeholder="6자리 인증번호"
value={code}
onChange={(e) => setCode(e.target.value)}
maxLength={6}
required
disabled={isLoading}
/>
<FieldDescription>
{timer > 0 ? `남은 시간: ${formatTime(timer)}` : "인증번호가 만료되었습니다"}
</FieldDescription>
</Field>
<Field>
<Button type="submit" onClick={handleVerifyCode} disabled={isLoading || timer === 0}>
{isLoading ? "확인 중..." : "인증번호 확인"}
</Button>
</Field>
<Field>
<Button type="button" variant="outline" onClick={() => setStep("info")} disabled={isLoading}>
</Button>
</Field>
</>
)}
{step === "reset" && (
<>
<Field>
<FieldLabel htmlFor="newPassword"> </FieldLabel>
<div className="relative">
<Input
id="newPassword"
type={showPassword ? "text" : "password"}
placeholder="8자 이상 입력하세요"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
disabled={isLoading}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2"
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
<FieldDescription>
8
</FieldDescription>
</Field>
<Field>
<FieldLabel htmlFor="confirmPassword"> </FieldLabel>
<div className="relative">
<Input
id="confirmPassword"
type={showConfirmPassword ? "text" : "password"}
placeholder="비밀번호를 다시 입력하세요"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
disabled={isLoading}
/>
<button
type="button"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2"
>
{showConfirmPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
{newPassword && confirmPassword && newPassword !== confirmPassword && (
<FieldDescription className="text-destructive">
</FieldDescription>
)}
</Field>
<Field>
<Button type="submit" onClick={handleResetPassword} disabled={isLoading}>
{isLoading ? "변경 중..." : "비밀번호 변경"}
</Button>
</Field>
</>
)}
{step === "complete" && (
<>
<Field>
<div className="bg-muted rounded-lg p-6 text-center">
<p className="text-lg font-semibold text-primary mb-2"> </p>
<p className="text-sm text-muted-foreground">
</p>
</div>
</Field>
<Field>
<Button type="button" onClick={handleGoToLogin}>
</Button>
</Field>
</>
)}
<Field className="-mt-5">
<Button variant="outline" type="button" onClick={() => router.push("/login")} className="w-full border-2 border-primary text-primary hover:bg-primary hover:text-primary-foreground hover:border-transparent transition-all duration-300">
</Button>
</Field>
<FieldSeparator> ?</FieldSeparator>
<Field>
<Button variant="outline" type="button" onClick={() => router.push("/signup")} className="w-full border-2 border-primary text-primary hover:bg-primary hover:text-primary-foreground hover:border-transparent transition-all duration-300">
</Button>
</Field>
</FieldGroup>
</form>
)
}

View File

@@ -0,0 +1,112 @@
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
FieldSeparator,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { useRouter } from "next/navigation";
import { Loader2 } from "lucide-react";
export function LoginForm({
className,
onSubmit,
userId,
setUserId,
userPassword,
setUserPassword,
error,
isLoading,
...props
}: React.ComponentProps<"form"> & {
onSubmit: (e: React.FormEvent) => void;
userId: string;
setUserId: (value: string) => void;
userPassword: string;
setUserPassword: (value: string) => void;
error?: string;
isLoading?: boolean;
}) {
const router = useRouter();
return (
<form className={cn("flex flex-col gap-4", className)} onSubmit={onSubmit} {...props}>
<FieldGroup>
<div className="flex flex-col items-center gap-1 text-center mb-2">
<h1 className="text-xl font-bold"></h1>
<p className="text-muted-foreground text-sm text-balance">
</p>
</div>
<Field>
<FieldLabel htmlFor="userId"></FieldLabel>
<Input
id="userId"
type="text"
value={userId}
onChange={(e) => setUserId(e.target.value)}
placeholder="아이디를 입력하세요"
disabled={isLoading}
required
/>
</Field>
<Field>
<div className="flex items-center">
<FieldLabel htmlFor="password"></FieldLabel>
</div>
<Input
id="password"
type="password"
value={userPassword}
onChange={(e) => setUserPassword(e.target.value)}
placeholder="비밀번호를 입력하세요"
disabled={isLoading}
required
/>
</Field>
{error && (
<div className="rounded-md bg-red-50 p-3 text-sm text-red-600">
{error}
</div>
)}
<Field>
<Button
type="submit"
className="w-full bg-primary hover:bg-primary/90 text-white shadow-lg hover:shadow-xl transition-all duration-300"
disabled={isLoading}
>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{isLoading ? '로그인 중...' : '로그인'}
</Button>
</Field>
<FieldSeparator> ?</FieldSeparator>
<Field>
<Button
variant="outline"
type="button"
onClick={() => router.push('/signup')}
disabled={isLoading}
className="w-full border-2 border-primary text-primary hover:bg-primary hover:text-primary-foreground hover:border-transparent transition-all duration-300"
>
</Button>
<FieldDescription className="text-center mt-2">
<a href="/findid" className="text-sm hover:underline underline-offset-4">
</a>
{" "}|{" "}
<a
href="/findpw"
className="text-sm hover:underline underline-offset-4">
</a>
</FieldDescription>
</Field>
</FieldGroup>
</form>
)
}

View File

@@ -0,0 +1,394 @@
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { useRouter } from "next/navigation"
import { Loader2, CheckCircle2, XCircle } from "lucide-react"
import { SignupFormData } from "@/types/auth.types"
import { Dispatch, SetStateAction } from "react"
interface SignupFormProps extends React.ComponentProps<"form"> {
onSubmit: (e: React.FormEvent) => void;
formData: SignupFormData;
setFormData: Dispatch<SetStateAction<SignupFormData>>;
error?: string;
isLoading?: boolean;
onSendVerificationCode: () => void;
isEmailVerified: boolean;
isSendingCode: boolean;
isCodeSent: boolean;
verificationCode: string;
setVerificationCode: (code: string) => void;
onVerifyCode: () => void;
isVerifyingCode: boolean;
emailCheckStatus: 'idle' | 'checking' | 'available' | 'unavailable';
emailCheckMessage: string;
}
export function SignupForm({
className,
onSubmit,
formData,
setFormData,
error,
isLoading,
onSendVerificationCode,
isEmailVerified,
isSendingCode,
isCodeSent,
verificationCode,
setVerificationCode,
onVerifyCode,
isVerifyingCode,
emailCheckStatus,
emailCheckMessage,
...props
}: SignupFormProps) {
const router = useRouter();
const handleChange = (field: keyof SignupFormData, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
const emailDomains = [
'gmail.com',
'naver.com',
'daum.net',
'hanmail.net',
'nate.com',
'kakao.com',
'직접입력',
];
return (
<form className={cn("flex flex-col gap-6", className)} onSubmit={onSubmit} {...props}>
<FieldGroup>
<div className="flex flex-col items-center gap-1 text-center">
<h1 className="text-2xl font-bold"></h1>
<p className="text-muted-foreground text-sm text-balance">
</p>
</div>
<Field>
<FieldLabel htmlFor="userSe"> *</FieldLabel>
<Select
value={formData.userSe}
onValueChange={(value) => handleChange('userSe', value)}
disabled={isLoading}
required
>
<SelectTrigger>
<SelectValue placeholder="회원 유형을 선택하세요" />
</SelectTrigger>
<SelectContent>
<SelectItem value="FARM"></SelectItem>
<SelectItem value="CNSLT"></SelectItem>
<SelectItem value="ORGAN"></SelectItem>
</SelectContent>
</Select>
<FieldDescription> .</FieldDescription>
</Field>
<Field>
<FieldLabel htmlFor="userId"> *</FieldLabel>
<Input
id="userId"
type="text"
value={formData.userId}
onChange={(e) => handleChange('userId', e.target.value)}
placeholder="아이디를 입력하세요 (4자 이상)"
disabled={isLoading}
required
/>
<FieldDescription>4 .</FieldDescription>
</Field>
<Field>
<FieldLabel htmlFor="userName"> *</FieldLabel>
<Input
id="userName"
type="text"
value={formData.userName}
onChange={(e) => handleChange('userName', e.target.value)}
placeholder="이름을 입력하세요 (2자 이상)"
disabled={isLoading}
required
/>
</Field>
<Field>
<FieldLabel htmlFor="userEmail"> *</FieldLabel>
<div className="flex flex-col gap-2">
<div className="flex gap-2 items-center">
<Input
id="emailId"
type="text"
value={formData.emailId}
onChange={(e) => {
const newEmailId = e.target.value;
setFormData((prev) => {
const domain = prev.emailDomain === '직접입력' ? '' : prev.emailDomain;
return {
...prev,
emailId: newEmailId,
userEmail: domain ? `${newEmailId}@${domain}` : newEmailId,
};
});
}}
placeholder="이메일 아이디"
disabled={isLoading || isEmailVerified}
required
className="flex-1"
/>
<span className="text-muted-foreground">@</span>
{formData.emailDomain === '직접입력' ? (
<div className="flex flex-1">
<Input
id="customDomain"
type="text"
placeholder="도메인 주소"
value={formData.customDomain || ''}
onChange={(e) => {
const newDomain = e.target.value;
setFormData((prev) => ({
...prev,
customDomain: newDomain,
userEmail: `${prev.emailId}@${newDomain}`,
}));
}}
disabled={isLoading || isEmailVerified}
required
className="flex-1 rounded-r-none border-r-0 h-9 !text-sm px-3 py-2"
/>
<Select
value=""
onValueChange={(value) => {
setFormData((prev) => ({
...prev,
emailDomain: value,
customDomain: '',
userEmail: `${prev.emailId}@${value}`,
}));
}}
disabled={isLoading || isEmailVerified}
>
<SelectTrigger className="w-[50px] rounded-l-none -ml-px px-2 h-9 [&>svg]:size-4">
<SelectValue />
</SelectTrigger>
<SelectContent>
{emailDomains.map((domain) => (
<SelectItem key={domain} value={domain}>
{domain}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : (
<Select
value={formData.emailDomain || undefined}
onValueChange={(value) => {
if (value !== '직접입력') {
setFormData((prev) => ({
...prev,
emailDomain: value,
userEmail: `${prev.emailId}@${value}`,
}));
} else {
setFormData((prev) => ({
...prev,
emailDomain: value,
customDomain: '',
userEmail: prev.emailId,
}));
}
}}
disabled={isLoading || isEmailVerified}
>
<SelectTrigger className="flex-1">
<SelectValue placeholder="도메인 선택" />
</SelectTrigger>
<SelectContent>
{emailDomains.map((domain) => (
<SelectItem key={domain} value={domain}>
{domain}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
<Button
type="button"
onClick={onSendVerificationCode}
disabled={
!formData.emailId ||
(formData.emailDomain === '직접입력' ? !formData.customDomain : !formData.emailDomain) ||
isLoading ||
isEmailVerified ||
isSendingCode ||
isCodeSent ||
emailCheckStatus === 'unavailable' ||
emailCheckStatus === 'checking'
}
variant={isCodeSent || isEmailVerified ? "secondary" : "outline"}
className="w-full"
>
{isSendingCode && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{isEmailVerified ? '인증완료' : isSendingCode ? '발송중...' : isCodeSent ? '발송완료' : '인증번호 발송'}
</Button>
</div>
{emailCheckMessage && !isEmailVerified && (
<div className={cn(
"flex items-center gap-2 text-sm",
emailCheckStatus === 'checking' && "text-muted-foreground",
emailCheckStatus === 'available' && "text-green-600",
emailCheckStatus === 'unavailable' && "text-red-600"
)}>
{emailCheckStatus === 'checking' && <Loader2 className="h-4 w-4 animate-spin" />}
{emailCheckStatus === 'available' && <CheckCircle2 className="h-4 w-4" />}
{emailCheckStatus === 'unavailable' && <XCircle className="h-4 w-4" />}
<span>{emailCheckMessage}</span>
</div>
)}
{isEmailVerified && (
<FieldDescription className="text-green-600">
.
</FieldDescription>
)}
</Field>
{!isEmailVerified && isCodeSent && formData.userEmail && (
<Field>
<FieldLabel htmlFor="verificationCode"></FieldLabel>
<div className="flex gap-2">
<Input
id="verificationCode"
type="text"
value={verificationCode}
onChange={(e) => setVerificationCode(e.target.value)}
placeholder="인증번호 6자리"
disabled={isLoading || isEmailVerified}
maxLength={6}
className="flex-1"
/>
<Button
type="button"
onClick={onVerifyCode}
disabled={!verificationCode || isLoading || isEmailVerified || isVerifyingCode}
variant="outline"
>
{isVerifyingCode && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{isVerifyingCode ? '확인중...' : '확인'}
</Button>
</div>
<FieldDescription> 6 .</FieldDescription>
</Field>
)}
<Field>
<FieldLabel htmlFor="userPhone"> *</FieldLabel>
<Input
id="userPhone"
type="tel"
value={formData.userPhone}
onChange={(e) => handleChange('userPhone', e.target.value)}
placeholder="010-0000-0000"
disabled={isLoading}
required
/>
</Field>
{formData.userSe !== 'FARM' && formData.userSe && (
<Field>
<FieldLabel htmlFor="userInstName"> *</FieldLabel>
<Input
id="userInstName"
type="text"
value={formData.userInstName || ''}
onChange={(e) => handleChange('userInstName', e.target.value)}
placeholder="소속 기관명을 입력하세요"
disabled={isLoading}
required={formData.userSe !== 'FARM'}
/>
<FieldDescription>/ .</FieldDescription>
</Field>
)}
<Field>
<FieldLabel htmlFor="userPassword"> *</FieldLabel>
<Input
id="userPassword"
type="password"
value={formData.userPassword}
onChange={(e) => handleChange('userPassword', e.target.value)}
placeholder="비밀번호를 입력하세요 (8자 이상)"
disabled={isLoading}
required
/>
<FieldDescription>8 .</FieldDescription>
</Field>
<Field>
<FieldLabel htmlFor="confirmPassword"> *</FieldLabel>
<Input
id="confirmPassword"
type="password"
value={formData.confirmPassword}
onChange={(e) => handleChange('confirmPassword', e.target.value)}
placeholder="비밀번호를 다시 입력하세요"
disabled={isLoading}
required
/>
</Field>
{error && (
<div className="rounded-md bg-red-50 p-3 text-sm text-red-600">
{error}
</div>
)}
<Field>
<Button
type="submit"
className="w-full"
disabled={isLoading || !isEmailVerified}
>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{isLoading ? '회원가입 중...' : '회원가입'}
</Button>
{!isEmailVerified && (
<FieldDescription className="text-center text-amber-600">
.
</FieldDescription>
)}
</Field>
<Field>
<Button
variant="outline"
type="button"
onClick={() => router.push('/login')}
disabled={isLoading}
className="w-full"
>
</Button>
</Field>
</FieldGroup>
</form>
)
}

View File

@@ -0,0 +1,4 @@
// ============================================
// 차트 컴포넌트
// ============================================
export { MptGaugeBar } from './mpt-gauge-bar';

View File

@@ -0,0 +1,261 @@
'use client'
/**
* MPT 혈액대사검사 게이지/바 차트
* 정상 범위 대비 현재 값의 위치를 시각적으로 표시
*/
interface MptGaugeBarProps {
name: string
value: number
unit: string
lowerLimit: number | null
upperLimit: number | null
category: string
regionAverage?: number // 보은군 평균 (선택)
}
export function MptGaugeBar({
name,
value,
unit,
lowerLimit,
upperLimit,
category,
regionAverage
}: MptGaugeBarProps) {
// 상태 계산
const getStatus = (): 'low' | 'normal' | 'high' => {
if (upperLimit !== null && value > upperLimit) return 'high'
if (lowerLimit !== null && value < lowerLimit) return 'low'
return 'normal'
}
const status = getStatus()
// 범위와 값 기반 시각화 계산
const calculateVisualization = () => {
if (lowerLimit === null || upperLimit === null) {
// 범위 정보 없음 - 단순 값 표시
return { position: 50, hasRange: false }
}
// 모든 값 수집 (현재값, 보은군평균, 정상범위)
const allValues = [value, lowerLimit, upperLimit]
if (regionAverage !== undefined) {
allValues.push(regionAverage)
}
const dataMin = Math.min(...allValues)
const dataMax = Math.max(...allValues)
const dataRange = dataMax - dataMin
// 데이터 범위의 20% 패딩 추가
const padding = dataRange * 0.2
const chartMin = dataMin - padding
const chartMax = dataMax + padding
const chartRange = chartMax - chartMin
// 값의 위치 계산 (0-100%)
let position = ((value - chartMin) / chartRange) * 100
position = Math.max(0, Math.min(100, position)) // 0-100% 범위로 제한
// 정상범위 시작/끝 위치
const normalStart = ((lowerLimit - chartMin) / chartRange) * 100
const normalEnd = ((upperLimit - chartMin) / chartRange) * 100
return {
position,
normalStart,
normalEnd,
hasRange: true,
chartMin,
chartMax,
chartRange,
}
}
const viz = calculateVisualization()
// 카테고리별 색상 (제거 - 사용 안 함)
// 상태별 색상 (부드러운 톤)
const getStatusColor = () => {
switch (status) {
case 'high': return {
bg: 'bg-gradient-to-r from-red-400 to-red-500',
text: 'text-red-700',
badgeBg: 'bg-red-50',
badgeBorder: 'border-red-200',
barBg: 'linear-gradient(to right, #f87171, #ef4444)' // red-400 to red-500
}
case 'low': return {
bg: 'bg-gradient-to-r from-blue-400 to-blue-500',
text: 'text-blue-700',
badgeBg: 'bg-blue-50',
badgeBorder: 'border-blue-200',
barBg: 'linear-gradient(to right, #60a5fa, #3b82f6)' // blue-400 to blue-500
}
case 'normal': return {
bg: 'bg-gradient-to-r from-green-400 to-green-500',
text: 'text-green-700',
badgeBg: 'bg-green-50',
badgeBorder: 'border-green-200',
barBg: 'linear-gradient(to right, #4ade80, #22c55e)' // green-400 to green-500
}
}
}
const getStatusText = () => {
switch (status) {
case 'high': return '높음 ↑'
case 'low': return '낮음 ↓'
case 'normal': return '정상'
}
}
const statusColors = getStatusColor()
return (
<div className="p-3 md:p-3.5 rounded-xl border-2 border-slate-200 bg-white shadow-sm hover:shadow-md transition-shadow">
{/* 항목명과 현재값 */}
<div className="flex items-center justify-between mb-2 md:mb-2.5">
<span className="text-xs md:text-sm font-bold text-slate-800">{name}</span>
<div className="flex items-baseline gap-1">
<span className={`text-lg md:text-xl font-bold ${statusColors.text}`}>
{value.toFixed(1)}
</span>
<span className="text-xs md:text-sm font-semibold text-slate-600">{unit}</span>
</div>
</div>
{/* 게이지 바 */}
{viz.hasRange ? (
<div className="mb-2 md:mb-2.5">
{/* 현재값 및 보은군 평균 표시 (바 위) */}
<div className="relative h-10 md:h-11 mb-1">
{/* 보은군 평균 표시 */}
{regionAverage !== undefined && viz.hasRange && (() => {
const regionPosition = ((regionAverage - viz.chartMin!) / viz.chartRange!) * 100;
return (
<div
className="absolute -translate-x-1/2 transition-all duration-500"
style={{ left: `${Math.max(0, Math.min(100, regionPosition))}%`, top: '0px' }}
>
<div className="px-1.5 md:px-2 py-0.5 md:py-1 rounded text-[10px] md:text-xs font-semibold whitespace-nowrap bg-slate-100 text-slate-700 border border-slate-300 shadow-sm">
: {regionAverage.toFixed(1)}
</div>
{/* 화살표 */}
<div
className="w-0 h-0 mx-auto"
style={{
borderLeft: '4px solid transparent',
borderRight: '4px solid transparent',
borderTop: '4px solid #94a3b8',
}}
/>
</div>
);
})()}
{/* 현재값 표시 */}
<div
className="absolute -translate-x-1/2 transition-all duration-500"
style={{ left: `${viz.position}%`, top: regionAverage !== undefined ? '24px' : '0px' }}
>
<div className={`px-1.5 md:px-2 py-0.5 md:py-1 rounded text-[10px] md:text-xs font-bold whitespace-nowrap shadow-sm border ${
status === 'high' ? 'bg-red-500 text-white border-red-600' :
status === 'low' ? 'bg-blue-500 text-white border-blue-600' :
'bg-green-500 text-white border-green-600'
}`}>
{value.toFixed(1)}
{regionAverage !== undefined && Math.abs(value - regionAverage) > 0.1 && (
<span className="ml-0.5">
({value > regionAverage ? '+' : ''}{(value - regionAverage).toFixed(1)})
</span>
)}
</div>
{/* 화살표 */}
<div
className="w-0 h-0 mx-auto"
style={{
borderLeft: '5px solid transparent',
borderRight: '5px solid transparent',
borderTop: `5px solid ${
status === 'high' ? '#ef4444' :
status === 'low' ? '#3b82f6' : '#22c55e'
}`,
}}
/>
</div>
</div>
<div className="relative h-3 md:h-4 bg-slate-100 rounded-full overflow-hidden border-2 border-slate-200 shadow-inner">
{/* 정상 범위 영역 */}
<div
className="absolute h-full bg-green-100/60"
style={{
left: `${viz.normalStart}%`,
width: `${viz.normalEnd! - viz.normalStart!}%`,
}}
/>
{/* 보은군 평균 인디케이터 (회색 실선) */}
{regionAverage !== undefined && viz.hasRange && (() => {
const regionPosition = ((regionAverage - viz.chartMin!) / viz.chartRange!) * 100;
return (
<div
className="absolute top-0 h-full w-[3px] transition-all duration-500 z-[9]"
style={{
left: `${Math.max(0, Math.min(100, regionPosition))}%`,
background: '#94a3b8',
}}
/>
);
})()}
{/* 현재 값 인디케이터 */}
<div
className="absolute top-0 h-full w-1.5 md:w-2 transition-all duration-500 shadow-md z-10 rounded-full"
style={{
left: `${viz.position}%`,
background: statusColors.barBg,
}}
/>
</div>
{/* 정상 범위 수치 표기 (바 양옆) */}
<div className="flex items-center justify-between mt-1.5 md:mt-2 px-0.5">
<div className="flex flex-col items-start">
<span className="text-[10px] md:text-xs text-slate-500 font-semibold"></span>
<span className="text-xs md:text-sm font-bold text-slate-700">{lowerLimit}</span>
</div>
<div className="flex items-center gap-1">
<span className={`px-1.5 md:px-2 py-0.5 md:py-1 rounded text-[10px] md:text-xs font-bold border-2 ${statusColors.badgeBg} ${statusColors.text} ${statusColors.badgeBorder}`}>
{getStatusText()}
</span>
</div>
<div className="flex flex-col items-end">
<span className="text-[10px] md:text-xs text-slate-500 font-semibold"></span>
<span className="text-xs md:text-sm font-bold text-slate-700">{upperLimit}</span>
</div>
</div>
</div>
) : (
<div className="mb-2">
<div className="h-3 md:h-4 bg-slate-100 rounded-full overflow-hidden border-2 border-slate-200 shadow-inner">
<div
className={`h-full rounded-full transition-all duration-500`}
style={{ width: '100%', background: statusColors.barBg }}
/>
</div>
<div className="mt-2 text-center">
<span className={`px-2 md:px-2.5 py-1 md:py-1.5 rounded text-[10px] md:text-xs font-bold border-2 ${statusColors.badgeBg} ${statusColors.text} ${statusColors.badgeBorder}`}>
{getStatusText()}
</span>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,57 @@
'use client';
import { useEffect, useState } from 'react';
export function AnimatedDesktop() {
const [svgContent, setSvgContent] = useState('');
useEffect(() => {
// SVG 파일을 fetch해서 내용을 가져옴
fetch('/images/Desktop_SVG.svg')
.then(res => res.text())
.then(text => {
// SVG 내용에 CSS 애니메이션을 추가
const parser = new DOMParser();
const svgDoc = parser.parseFromString(text, 'image/svg+xml');
const svg = svgDoc.documentElement;
// style 태그 추가
const style = svgDoc.createElementNS('http://www.w3.org/2000/svg', 'style');
style.textContent = `
@keyframes rotate-circle {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.rotating-element {
animation: rotate-circle 4s linear infinite;
transform-origin: center;
transform-box: fill-box;
}
`;
svg.insertBefore(style, svg.firstChild);
// 회전시킬 요소 찾기 (fill="#38BDD4" and transform="translate(813,386)")
const paths = svg.querySelectorAll('path');
paths.forEach(path => {
const fill = path.getAttribute('fill');
const transform = path.getAttribute('transform');
if (fill === '#38BDD4' && transform && transform.includes('translate(813,386)')) {
path.classList.add('rotating-element');
}
});
// 수정된 SVG를 문자열로 변환
const serializer = new XMLSerializer();
const modifiedSvg = serializer.serializeToString(svg);
setSvgContent(modifiedSvg);
})
.catch(console.error);
}, []);
return (
<div
className="relative w-full h-full flex items-center justify-center"
dangerouslySetInnerHTML={{ __html: svgContent }}
/>
);
}

View File

@@ -0,0 +1,91 @@
/**
* 개체번호 표시 컴포넌트
* 형식: "KOR 001 1223 3440 1" (3-4-4-1 띄어쓰기)
* cowShortNo(요약번호 4자리)를 강조 표시
*/
interface CowNumberDisplayProps {
cowId: string | number // 개체식별번호 (KOR로 시작하는 문자열 또는 숫자)
cowShortNo?: string
className?: string
variant?: 'default' | 'light' | 'highlight' // highlight: 배경색 강조
format?: 'full' | 'noKor' | 'shortOnly' // full: KOR 포함, noKor: KOR 제외, shortOnly: 4자리만
}
/**
* cowId에서 cowShortNo를 자동 계산하는 함수
* 규칙: 숫자만 추출 → 마지막 자리 제외 → 뒤에서 4자리
* 예: 001122334401 → 2334
*/
function extractShortNo(cowId: string): string {
const digits = cowId.replace(/\D/g, '')
if (digits.length < 5) return ''
return digits.slice(-5, -1)
}
export function CowNumberDisplay({ cowId, cowShortNo, className = '', variant = 'default', format = 'full' }: CowNumberDisplayProps) {
const cowIdStr = String(cowId || '')
const cleaned = cowIdStr.replace(/\D/g, '')
if (cleaned.length !== 12) {
return <span className={className}>{cowId}</span>
}
const shortNo = cowShortNo || extractShortNo(cowIdStr)
const part1 = cleaned.slice(0, 3)
const part2 = cleaned.slice(3, 7)
const part3 = cleaned.slice(7, 11)
const part4 = cleaned.slice(11)
// variant에 따른 강조 스타일
const getHighlightClass = () => {
switch (variant) {
case 'light':
return 'text-white font-bold'
case 'highlight':
return 'bg-primary/15 text-primary font-bold px-1.5 py-0.5 rounded'
default:
return 'bg-blue-100 text-blue-700 font-bold px-1.5 py-0.5 rounded'
}
}
// shortOnly: 4자리만 표시
if (format === 'shortOnly') {
return (
<span className={className}>
<span className={getHighlightClass()}>{part3}</span>
</span>
)
}
// noKor: KOR 제외
if (format === 'noKor') {
if (shortNo && shortNo.length === 4) {
return (
<span className={className}>
{part1} {part2} <span className={getHighlightClass()}>{part3}</span> {part4}
</span>
)
}
return (
<span className={className}>
{part1} {part2} {part3} {part4}
</span>
)
}
// full: KOR 포함 (기본값)
if (shortNo && shortNo.length === 4) {
return (
<span className={className}>
KOR {part1} {part2} <span className={getHighlightClass()}>{part3}</span> {part4}
</span>
)
}
return (
<span className={className}>
KOR {part1} {part2} {part3} {part4}
</span>
)
}

View File

@@ -0,0 +1,807 @@
"use client"
import * as React from "react"
import {
closestCenter,
DndContext,
KeyboardSensor,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
type DragEndEvent,
type UniqueIdentifier,
} from "@dnd-kit/core"
import { restrictToVerticalAxis } from "@dnd-kit/modifiers"
import {
arrayMove,
SortableContext,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import {
IconChevronDown,
IconChevronLeft,
IconChevronRight,
IconChevronsLeft,
IconChevronsRight,
IconCircleCheckFilled,
IconDotsVertical,
IconGripVertical,
IconLayoutColumns,
IconLoader,
IconPlus,
IconTrendingUp,
} from "@tabler/icons-react"
import {
ColumnDef,
ColumnFiltersState,
flexRender,
getCoreRowModel,
getFacetedRowModel,
getFacetedUniqueValues,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
Row,
SortingState,
useReactTable,
VisibilityState,
} from "@tanstack/react-table"
import { Area, AreaChart, CartesianGrid, XAxis } from "recharts"
import { toast } from "sonner"
import { z } from "zod"
import { useIsMobile } from "@/hooks/use-mobile"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart"
import { Checkbox } from "@/components/ui/checkbox"
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Separator } from "@/components/ui/separator"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/ui/tabs"
export const schema = z.object({
id: z.number(),
header: z.string(),
type: z.string(),
status: z.string(),
target: z.string(),
limit: z.string(),
reviewer: z.string(),
})
// Create a separate component for the drag handle
function DragHandle({ id }: { id: number }) {
const { attributes, listeners } = useSortable({
id,
})
return (
<Button
{...attributes}
{...listeners}
variant="ghost"
size="icon"
className="text-muted-foreground size-7 hover:bg-transparent"
>
<IconGripVertical className="text-muted-foreground size-3" />
<span className="sr-only">Drag to reorder</span>
</Button>
)
}
const columns: ColumnDef<z.infer<typeof schema>>[] = [
{
id: "drag",
header: () => null,
cell: ({ row }) => <DragHandle id={row.original.id} />,
},
{
id: "select",
header: ({ table }) => (
<div className="flex items-center justify-center">
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
/>
</div>
),
cell: ({ row }) => (
<div className="flex items-center justify-center">
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
</div>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: "header",
header: "Header",
cell: ({ row }) => {
return <TableCellViewer item={row.original} />
},
enableHiding: false,
},
{
accessorKey: "type",
header: "Section Type",
cell: ({ row }) => (
<div className="w-32">
<Badge variant="outline" className="text-muted-foreground px-1.5">
{row.original.type}
</Badge>
</div>
),
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => (
<Badge variant="outline" className="text-muted-foreground px-1.5">
{row.original.status === "Done" ? (
<IconCircleCheckFilled className="fill-green-500 dark:fill-green-400" />
) : (
<IconLoader />
)}
{row.original.status}
</Badge>
),
},
{
accessorKey: "target",
header: () => <div className="w-full text-right">Target</div>,
cell: ({ row }) => (
<form
onSubmit={(e) => {
e.preventDefault()
toast.promise(new Promise((resolve) => setTimeout(resolve, 1000)), {
loading: `Saving ${row.original.header}`,
success: "Done",
error: "Error",
})
}}
>
<Label htmlFor={`${row.original.id}-target`} className="sr-only">
Target
</Label>
<Input
className="hover:bg-input/30 focus-visible:bg-background dark:hover:bg-input/30 dark:focus-visible:bg-input/30 h-8 w-16 border-transparent bg-transparent text-right shadow-none focus-visible:border dark:bg-transparent"
defaultValue={row.original.target}
id={`${row.original.id}-target`}
/>
</form>
),
},
{
accessorKey: "limit",
header: () => <div className="w-full text-right">Limit</div>,
cell: ({ row }) => (
<form
onSubmit={(e) => {
e.preventDefault()
toast.promise(new Promise((resolve) => setTimeout(resolve, 1000)), {
loading: `Saving ${row.original.header}`,
success: "Done",
error: "Error",
})
}}
>
<Label htmlFor={`${row.original.id}-limit`} className="sr-only">
Limit
</Label>
<Input
className="hover:bg-input/30 focus-visible:bg-background dark:hover:bg-input/30 dark:focus-visible:bg-input/30 h-8 w-16 border-transparent bg-transparent text-right shadow-none focus-visible:border dark:bg-transparent"
defaultValue={row.original.limit}
id={`${row.original.id}-limit`}
/>
</form>
),
},
{
accessorKey: "reviewer",
header: "Reviewer",
cell: ({ row }) => {
const isAssigned = row.original.reviewer !== "Assign reviewer"
if (isAssigned) {
return row.original.reviewer
}
return (
<>
<Label htmlFor={`${row.original.id}-reviewer`} className="sr-only">
Reviewer
</Label>
<Select>
<SelectTrigger
className="w-38 **:data-[slot=select-value]:block **:data-[slot=select-value]:truncate"
size="sm"
id={`${row.original.id}-reviewer`}
>
<SelectValue placeholder="Assign reviewer" />
</SelectTrigger>
<SelectContent align="end">
<SelectItem value="Eddie Lake">Eddie Lake</SelectItem>
<SelectItem value="Jamik Tashpulatov">
Jamik Tashpulatov
</SelectItem>
</SelectContent>
</Select>
</>
)
},
},
{
id: "actions",
cell: () => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="data-[state=open]:bg-muted text-muted-foreground flex size-8"
size="icon"
>
<IconDotsVertical />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-32">
<DropdownMenuItem>Edit</DropdownMenuItem>
<DropdownMenuItem>Make a copy</DropdownMenuItem>
<DropdownMenuItem>Favorite</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">Delete</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
},
]
function DraggableRow({ row }: { row: Row<z.infer<typeof schema>> }) {
const { transform, transition, setNodeRef, isDragging } = useSortable({
id: row.original.id,
})
return (
<TableRow
data-state={row.getIsSelected() && "selected"}
data-dragging={isDragging}
ref={setNodeRef}
className="relative z-0 data-[dragging=true]:z-10 data-[dragging=true]:opacity-80"
style={{
transform: CSS.Transform.toString(transform),
transition: transition,
}}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
)
}
export function DataTable({
data: initialData,
}: {
data: z.infer<typeof schema>[]
}) {
const [data, setData] = React.useState(() => initialData)
const [rowSelection, setRowSelection] = React.useState({})
const [columnVisibility, setColumnVisibility] =
React.useState<VisibilityState>({})
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
[]
)
const [sorting, setSorting] = React.useState<SortingState>([])
const [pagination, setPagination] = React.useState({
pageIndex: 0,
pageSize: 10,
})
const sortableId = React.useId()
const sensors = useSensors(
useSensor(MouseSensor, {}),
useSensor(TouchSensor, {}),
useSensor(KeyboardSensor, {})
)
const dataIds = React.useMemo<UniqueIdentifier[]>(
() => data?.map(({ id }) => id) || [],
[data]
)
const table = useReactTable({
data,
columns,
state: {
sorting,
columnVisibility,
rowSelection,
columnFilters,
pagination,
},
getRowId: (row) => row.id.toString(),
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: getFacetedUniqueValues(),
})
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event
if (active && over && active.id !== over.id) {
setData((data) => {
const oldIndex = dataIds.indexOf(active.id)
const newIndex = dataIds.indexOf(over.id)
return arrayMove(data, oldIndex, newIndex)
})
}
}
return (
<Tabs
defaultValue="outline"
className="w-full flex-col justify-start gap-6"
>
<div className="flex items-center justify-between px-4 lg:px-6">
<Label htmlFor="view-selector" className="sr-only">
View
</Label>
<Select defaultValue="outline">
<SelectTrigger
className="flex w-fit @4xl/main:hidden"
size="sm"
id="view-selector"
>
<SelectValue placeholder="Select a view" />
</SelectTrigger>
<SelectContent>
<SelectItem value="outline">Outline</SelectItem>
<SelectItem value="past-performance">Past Performance</SelectItem>
<SelectItem value="key-personnel">Key Personnel</SelectItem>
<SelectItem value="focus-documents">Focus Documents</SelectItem>
</SelectContent>
</Select>
<TabsList className="**:data-[slot=badge]:bg-muted-foreground/30 hidden **:data-[slot=badge]:size-5 **:data-[slot=badge]:rounded-full **:data-[slot=badge]:px-1 @4xl/main:flex">
<TabsTrigger value="outline">Outline</TabsTrigger>
<TabsTrigger value="past-performance">
Past Performance <Badge variant="secondary">3</Badge>
</TabsTrigger>
<TabsTrigger value="key-personnel">
Key Personnel <Badge variant="secondary">2</Badge>
</TabsTrigger>
<TabsTrigger value="focus-documents">Focus Documents</TabsTrigger>
</TabsList>
<div className="flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<IconLayoutColumns />
<span className="hidden lg:inline">Customize Columns</span>
<span className="lg:hidden">Columns</span>
<IconChevronDown />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
{table
.getAllColumns()
.filter(
(column) =>
typeof column.accessorFn !== "undefined" &&
column.getCanHide()
)
.map((column) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize"
checked={column.getIsVisible()}
onCheckedChange={(value) =>
column.toggleVisibility(!!value)
}
>
{column.id}
</DropdownMenuCheckboxItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
<Button variant="outline" size="sm">
<IconPlus />
<span className="hidden lg:inline">Add Section</span>
</Button>
</div>
</div>
<TabsContent
value="outline"
className="relative flex flex-col gap-4 overflow-auto px-4 lg:px-6"
>
<div className="overflow-hidden rounded-lg border">
<DndContext
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis]}
onDragEnd={handleDragEnd}
sensors={sensors}
id={sortableId}
>
<Table>
<TableHeader className="bg-muted sticky top-0 z-10">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id} colSpan={header.colSpan}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
<TableBody className="**:data-[slot=table-cell]:first:w-8">
{table.getRowModel().rows?.length ? (
<SortableContext
items={dataIds}
strategy={verticalListSortingStrategy}
>
{table.getRowModel().rows.map((row) => (
<DraggableRow key={row.id} row={row} />
))}
</SortableContext>
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</DndContext>
</div>
<div className="flex items-center justify-between px-4">
<div className="text-muted-foreground hidden flex-1 text-sm lg:flex">
{table.getFilteredSelectedRowModel().rows.length} of{" "}
{table.getFilteredRowModel().rows.length} row(s) selected.
</div>
<div className="flex w-full items-center gap-8 lg:w-fit">
<div className="hidden items-center gap-2 lg:flex">
<Label htmlFor="rows-per-page" className="text-sm font-medium">
Rows per page
</Label>
<Select
value={`${table.getState().pagination.pageSize}`}
onValueChange={(value) => {
table.setPageSize(Number(value))
}}
>
<SelectTrigger size="sm" className="w-20" id="rows-per-page">
<SelectValue
placeholder={table.getState().pagination.pageSize}
/>
</SelectTrigger>
<SelectContent side="top">
{[10, 20, 30, 40, 50].map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex w-fit items-center justify-center text-sm font-medium">
Page {table.getState().pagination.pageIndex + 1} of{" "}
{table.getPageCount()}
</div>
<div className="ml-auto flex items-center gap-2 lg:ml-0">
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to first page</span>
<IconChevronsLeft />
</Button>
<Button
variant="outline"
className="size-8"
size="icon"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to previous page</span>
<IconChevronLeft />
</Button>
<Button
variant="outline"
className="size-8"
size="icon"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to next page</span>
<IconChevronRight />
</Button>
<Button
variant="outline"
className="hidden size-8 lg:flex"
size="icon"
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to last page</span>
<IconChevronsRight />
</Button>
</div>
</div>
</div>
</TabsContent>
<TabsContent
value="past-performance"
className="flex flex-col px-4 lg:px-6"
>
<div className="aspect-video w-full flex-1 rounded-lg border border-dashed"></div>
</TabsContent>
<TabsContent value="key-personnel" className="flex flex-col px-4 lg:px-6">
<div className="aspect-video w-full flex-1 rounded-lg border border-dashed"></div>
</TabsContent>
<TabsContent
value="focus-documents"
className="flex flex-col px-4 lg:px-6"
>
<div className="aspect-video w-full flex-1 rounded-lg border border-dashed"></div>
</TabsContent>
</Tabs>
)
}
const chartData = [
{ month: "January", desktop: 186, mobile: 80 },
{ month: "February", desktop: 305, mobile: 200 },
{ month: "March", desktop: 237, mobile: 120 },
{ month: "April", desktop: 73, mobile: 190 },
{ month: "May", desktop: 209, mobile: 130 },
{ month: "June", desktop: 214, mobile: 140 },
]
const chartConfig = {
desktop: {
label: "Desktop",
color: "var(--primary)",
},
mobile: {
label: "Mobile",
color: "var(--primary)",
},
} satisfies ChartConfig
function TableCellViewer({ item }: { item: z.infer<typeof schema> }) {
const isMobile = useIsMobile()
return (
<Drawer direction={isMobile ? "bottom" : "right"}>
<DrawerTrigger asChild>
<Button variant="link" className="text-foreground w-fit px-0 text-left">
{item.header}
</Button>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader className="gap-1">
<DrawerTitle>{item.header}</DrawerTitle>
<DrawerDescription>
Showing total visitors for the last 6 months
</DrawerDescription>
</DrawerHeader>
<div className="flex flex-col gap-4 overflow-y-auto px-4 text-sm">
{!isMobile && (
<>
<ChartContainer config={chartConfig}>
<AreaChart
accessibilityLayer
data={chartData}
margin={{
left: 0,
right: 10,
}}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="month"
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value) => value.slice(0, 3)}
hide
/>
<ChartTooltip
cursor={false}
content={<ChartTooltipContent indicator="dot" />}
/>
<Area
dataKey="mobile"
type="natural"
fill="var(--color-mobile)"
fillOpacity={0.6}
stroke="var(--color-mobile)"
stackId="a"
/>
<Area
dataKey="desktop"
type="natural"
fill="var(--color-desktop)"
fillOpacity={0.4}
stroke="var(--color-desktop)"
stackId="a"
/>
</AreaChart>
</ChartContainer>
<Separator />
<div className="grid gap-2">
<div className="flex gap-2 leading-none font-medium">
Trending up by 5.2% this month{" "}
<IconTrendingUp className="size-4" />
</div>
<div className="text-muted-foreground">
Showing total visitors for the last 6 months. This is just
some random text to test the layout. It spans multiple lines
and should wrap around.
</div>
</div>
<Separator />
</>
)}
<form className="flex flex-col gap-4">
<div className="flex flex-col gap-3">
<Label htmlFor="header">Header</Label>
<Input id="header" defaultValue={item.header} />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-3">
<Label htmlFor="type">Type</Label>
<Select defaultValue={item.type}>
<SelectTrigger id="type" className="w-full">
<SelectValue placeholder="Select a type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Table of Contents">
Table of Contents
</SelectItem>
<SelectItem value="Executive Summary">
Executive Summary
</SelectItem>
<SelectItem value="Technical Approach">
Technical Approach
</SelectItem>
<SelectItem value="Design">Design</SelectItem>
<SelectItem value="Capabilities">Capabilities</SelectItem>
<SelectItem value="Focus Documents">
Focus Documents
</SelectItem>
<SelectItem value="Narrative">Narrative</SelectItem>
<SelectItem value="Cover Page">Cover Page</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-3">
<Label htmlFor="status">Status</Label>
<Select defaultValue={item.status}>
<SelectTrigger id="status" className="w-full">
<SelectValue placeholder="Select a status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Done">Done</SelectItem>
<SelectItem value="In Progress">In Progress</SelectItem>
<SelectItem value="Not Started">Not Started</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-3">
<Label htmlFor="target">Target</Label>
<Input id="target" defaultValue={item.target} />
</div>
<div className="flex flex-col gap-3">
<Label htmlFor="limit">Limit</Label>
<Input id="limit" defaultValue={item.limit} />
</div>
</div>
<div className="flex flex-col gap-3">
<Label htmlFor="reviewer">Reviewer</Label>
<Select defaultValue={item.reviewer}>
<SelectTrigger id="reviewer" className="w-full">
<SelectValue placeholder="Select a reviewer" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Eddie Lake">Eddie Lake</SelectItem>
<SelectItem value="Jamik Tashpulatov">
Jamik Tashpulatov
</SelectItem>
<SelectItem value="Emily Whalen">Emily Whalen</SelectItem>
</SelectContent>
</Select>
</div>
</form>
</div>
<DrawerFooter>
<Button>Submit</Button>
<DrawerClose asChild>
<Button variant="outline">Done</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,56 @@
'use client'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, Cell } from 'recharts'
// 벤치마크 데이터
const benchmarkData = [
{ category: '유전체 점수', myFarm: 79.8, regional: 65.2, top10: 88.5 },
{ category: 'MPT 충족률', myFarm: 74.6, regional: 72.0, top10: 85.2 },
{ category: 'A등급 비율', myFarm: 37.5, regional: 18.8, top10: 45.0 },
{ category: '번식능력', myFarm: 72, regional: 70, top10: 82 },
]
export function RegionBenchmark() {
return (
<Card className="shadow-sm">
<CardHeader className="pb-2 md:pb-3">
<CardTitle className="text-xs md:text-sm font-semibold"> </CardTitle>
<CardDescription className="text-[11px] md:text-xs mt-0.5">
vs vs 10%
</CardDescription>
</CardHeader>
<CardContent className="pt-0 pb-3 md:pb-4">
<ResponsiveContainer width="100%" height={280}>
<BarChart data={benchmarkData} barGap={4}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis
dataKey="category"
tick={{ fontSize: 11 }}
stroke="#6b7280"
/>
<YAxis
tick={{ fontSize: 11 }}
stroke="#6b7280"
domain={[0, 100]}
/>
<Tooltip
contentStyle={{
backgroundColor: 'white',
border: '1px solid #e5e7eb',
borderRadius: '6px',
fontSize: '11px'
}}
formatter={(value: number) => `${value.toFixed(1)}`}
/>
<Legend wrapperStyle={{ fontSize: '11px' }} />
<Bar dataKey="regional" fill="#9ca3af" name="보은군 평균" radius={[4, 4, 0, 0]} />
<Bar dataKey="myFarm" fill="#2563eb" name="내 농장" radius={[4, 4, 0, 0]} />
<Bar dataKey="top10" fill="#10b981" name="상위 10%" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,291 @@
"use client"
import * as React from "react"
import { Area, AreaChart, CartesianGrid, XAxis } from "recharts"
import { useIsMobile } from "@/hooks/use-mobile"
import {
Card,
CardAction,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
ToggleGroup,
ToggleGroupItem,
} from "@/components/ui/toggle-group"
export const description = "An interactive area chart"
const chartData = [
{ date: "2024-04-01", desktop: 222, mobile: 150 },
{ date: "2024-04-02", desktop: 97, mobile: 180 },
{ date: "2024-04-03", desktop: 167, mobile: 120 },
{ date: "2024-04-04", desktop: 242, mobile: 260 },
{ date: "2024-04-05", desktop: 373, mobile: 290 },
{ date: "2024-04-06", desktop: 301, mobile: 340 },
{ date: "2024-04-07", desktop: 245, mobile: 180 },
{ date: "2024-04-08", desktop: 409, mobile: 320 },
{ date: "2024-04-09", desktop: 59, mobile: 110 },
{ date: "2024-04-10", desktop: 261, mobile: 190 },
{ date: "2024-04-11", desktop: 327, mobile: 350 },
{ date: "2024-04-12", desktop: 292, mobile: 210 },
{ date: "2024-04-13", desktop: 342, mobile: 380 },
{ date: "2024-04-14", desktop: 137, mobile: 220 },
{ date: "2024-04-15", desktop: 120, mobile: 170 },
{ date: "2024-04-16", desktop: 138, mobile: 190 },
{ date: "2024-04-17", desktop: 446, mobile: 360 },
{ date: "2024-04-18", desktop: 364, mobile: 410 },
{ date: "2024-04-19", desktop: 243, mobile: 180 },
{ date: "2024-04-20", desktop: 89, mobile: 150 },
{ date: "2024-04-21", desktop: 137, mobile: 200 },
{ date: "2024-04-22", desktop: 224, mobile: 170 },
{ date: "2024-04-23", desktop: 138, mobile: 230 },
{ date: "2024-04-24", desktop: 387, mobile: 290 },
{ date: "2024-04-25", desktop: 215, mobile: 250 },
{ date: "2024-04-26", desktop: 75, mobile: 130 },
{ date: "2024-04-27", desktop: 383, mobile: 420 },
{ date: "2024-04-28", desktop: 122, mobile: 180 },
{ date: "2024-04-29", desktop: 315, mobile: 240 },
{ date: "2024-04-30", desktop: 454, mobile: 380 },
{ date: "2024-05-01", desktop: 165, mobile: 220 },
{ date: "2024-05-02", desktop: 293, mobile: 310 },
{ date: "2024-05-03", desktop: 247, mobile: 190 },
{ date: "2024-05-04", desktop: 385, mobile: 420 },
{ date: "2024-05-05", desktop: 481, mobile: 390 },
{ date: "2024-05-06", desktop: 498, mobile: 520 },
{ date: "2024-05-07", desktop: 388, mobile: 300 },
{ date: "2024-05-08", desktop: 149, mobile: 210 },
{ date: "2024-05-09", desktop: 227, mobile: 180 },
{ date: "2024-05-10", desktop: 293, mobile: 330 },
{ date: "2024-05-11", desktop: 335, mobile: 270 },
{ date: "2024-05-12", desktop: 197, mobile: 240 },
{ date: "2024-05-13", desktop: 197, mobile: 160 },
{ date: "2024-05-14", desktop: 448, mobile: 490 },
{ date: "2024-05-15", desktop: 473, mobile: 380 },
{ date: "2024-05-16", desktop: 338, mobile: 400 },
{ date: "2024-05-17", desktop: 499, mobile: 420 },
{ date: "2024-05-18", desktop: 315, mobile: 350 },
{ date: "2024-05-19", desktop: 235, mobile: 180 },
{ date: "2024-05-20", desktop: 177, mobile: 230 },
{ date: "2024-05-21", desktop: 82, mobile: 140 },
{ date: "2024-05-22", desktop: 81, mobile: 120 },
{ date: "2024-05-23", desktop: 252, mobile: 290 },
{ date: "2024-05-24", desktop: 294, mobile: 220 },
{ date: "2024-05-25", desktop: 201, mobile: 250 },
{ date: "2024-05-26", desktop: 213, mobile: 170 },
{ date: "2024-05-27", desktop: 420, mobile: 460 },
{ date: "2024-05-28", desktop: 233, mobile: 190 },
{ date: "2024-05-29", desktop: 78, mobile: 130 },
{ date: "2024-05-30", desktop: 340, mobile: 280 },
{ date: "2024-05-31", desktop: 178, mobile: 230 },
{ date: "2024-06-01", desktop: 178, mobile: 200 },
{ date: "2024-06-02", desktop: 470, mobile: 410 },
{ date: "2024-06-03", desktop: 103, mobile: 160 },
{ date: "2024-06-04", desktop: 439, mobile: 380 },
{ date: "2024-06-05", desktop: 88, mobile: 140 },
{ date: "2024-06-06", desktop: 294, mobile: 250 },
{ date: "2024-06-07", desktop: 323, mobile: 370 },
{ date: "2024-06-08", desktop: 385, mobile: 320 },
{ date: "2024-06-09", desktop: 438, mobile: 480 },
{ date: "2024-06-10", desktop: 155, mobile: 200 },
{ date: "2024-06-11", desktop: 92, mobile: 150 },
{ date: "2024-06-12", desktop: 492, mobile: 420 },
{ date: "2024-06-13", desktop: 81, mobile: 130 },
{ date: "2024-06-14", desktop: 426, mobile: 380 },
{ date: "2024-06-15", desktop: 307, mobile: 350 },
{ date: "2024-06-16", desktop: 371, mobile: 310 },
{ date: "2024-06-17", desktop: 475, mobile: 520 },
{ date: "2024-06-18", desktop: 107, mobile: 170 },
{ date: "2024-06-19", desktop: 341, mobile: 290 },
{ date: "2024-06-20", desktop: 408, mobile: 450 },
{ date: "2024-06-21", desktop: 169, mobile: 210 },
{ date: "2024-06-22", desktop: 317, mobile: 270 },
{ date: "2024-06-23", desktop: 480, mobile: 530 },
{ date: "2024-06-24", desktop: 132, mobile: 180 },
{ date: "2024-06-25", desktop: 141, mobile: 190 },
{ date: "2024-06-26", desktop: 434, mobile: 380 },
{ date: "2024-06-27", desktop: 448, mobile: 490 },
{ date: "2024-06-28", desktop: 149, mobile: 200 },
{ date: "2024-06-29", desktop: 103, mobile: 160 },
{ date: "2024-06-30", desktop: 446, mobile: 400 },
]
const chartConfig = {
visitors: {
label: "Visitors",
},
desktop: {
label: "Desktop",
color: "var(--primary)",
},
mobile: {
label: "Mobile",
color: "var(--primary)",
},
} satisfies ChartConfig
export function ChartAreaInteractive() {
const isMobile = useIsMobile()
const [timeRange, setTimeRange] = React.useState("90d")
React.useEffect(() => {
if (isMobile) {
setTimeRange("7d")
}
}, [isMobile])
const filteredData = chartData.filter((item) => {
const date = new Date(item.date)
const referenceDate = new Date("2024-06-30")
let daysToSubtract = 90
if (timeRange === "30d") {
daysToSubtract = 30
} else if (timeRange === "7d") {
daysToSubtract = 7
}
const startDate = new Date(referenceDate)
startDate.setDate(startDate.getDate() - daysToSubtract)
return date >= startDate
})
return (
<Card className="@container/card">
<CardHeader>
<CardTitle>Total Visitors</CardTitle>
<CardDescription>
<span className="hidden @[540px]/card:block">
Total for the last 3 months
</span>
<span className="@[540px]/card:hidden">Last 3 months</span>
</CardDescription>
<CardAction>
<ToggleGroup
type="single"
value={timeRange}
onValueChange={setTimeRange}
variant="outline"
className="hidden *:data-[slot=toggle-group-item]:!px-4 @[767px]/card:flex"
>
<ToggleGroupItem value="90d">Last 3 months</ToggleGroupItem>
<ToggleGroupItem value="30d">Last 30 days</ToggleGroupItem>
<ToggleGroupItem value="7d">Last 7 days</ToggleGroupItem>
</ToggleGroup>
<Select value={timeRange} onValueChange={setTimeRange}>
<SelectTrigger
className="flex w-40 **:data-[slot=select-value]:block **:data-[slot=select-value]:truncate @[767px]/card:hidden"
size="sm"
aria-label="Select a value"
>
<SelectValue placeholder="Last 3 months" />
</SelectTrigger>
<SelectContent className="rounded-lg">
<SelectItem value="90d" className="rounded-lg">
Last 3 months
</SelectItem>
<SelectItem value="30d" className="rounded-lg">
Last 30 days
</SelectItem>
<SelectItem value="7d" className="rounded-lg">
Last 7 days
</SelectItem>
</SelectContent>
</Select>
</CardAction>
</CardHeader>
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
<ChartContainer
config={chartConfig}
className="aspect-auto h-[250px] w-full"
>
<AreaChart data={filteredData}>
<defs>
<linearGradient id="fillDesktop" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="var(--color-desktop)"
stopOpacity={1.0}
/>
<stop
offset="95%"
stopColor="var(--color-desktop)"
stopOpacity={0.1}
/>
</linearGradient>
<linearGradient id="fillMobile" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="var(--color-mobile)"
stopOpacity={0.8}
/>
<stop
offset="95%"
stopColor="var(--color-mobile)"
stopOpacity={0.1}
/>
</linearGradient>
</defs>
<CartesianGrid vertical={false} />
<XAxis
dataKey="date"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={32}
tickFormatter={(value) => {
const date = new Date(value)
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
})
}}
/>
<ChartTooltip
cursor={false}
content={
<ChartTooltipContent
labelFormatter={(value) => {
return new Date(value).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
})
}}
indicator="dot"
/>
}
/>
<Area
dataKey="mobile"
type="natural"
fill="url(#fillMobile)"
stroke="var(--color-mobile)"
stackId="a"
/>
<Area
dataKey="desktop"
type="natural"
fill="url(#fillDesktop)"
stroke="var(--color-desktop)"
stackId="a"
/>
</AreaChart>
</ChartContainer>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,230 @@
'use client'
import { Card, CardContent } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { TrendingUp, TrendingDown, Minus, Users, Heart, Activity } from "lucide-react"
import { IconTrendingUp, IconTrendingDown, IconMoodNeutral } from "@tabler/icons-react"
import { useEffect, useState } from "react"
import { dashboardApi, breedApi, cowApi } from "@/lib/api"
import { FarmSummaryDto, FarmEvaluationDto } from "@/types/dashboard.types"
import { useAuthStore } from "@/store/auth-store"
import { useGlobalFilter } from "@/contexts/GlobalFilterContext"
interface KPIData {
label: string
value: string | number
change: number
changeLabel: string
icon: React.ReactNode
color: string
badge?: React.ReactNode
subtext?: string
}
interface KPIDashboardProps {
farmNo: number
}
export function KPIDashboard({ farmNo }: KPIDashboardProps) {
const { user } = useAuthStore()
const { filters } = useGlobalFilter()
const [kpiData, setKpiData] = useState<KPIData[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
const fetchData = async () => {
// farmNo가 없으면 데이터 로드하지 않음
if (!farmNo) {
console.warn('farmNo가 없습니다.')
setLoading(false)
return
}
setLoading(true)
try {
// 필터를 백엔드 DTO 형식으로 변환
const filterDto = {
targetGenes: filters.selectedGenes?.length > 0 ? filters.selectedGenes : undefined,
// 추후 필요시 다른 필터 추가 가능
}
// 실제 API 데이터 가져오기
const [cows, evaluationData, breedSaves] = await Promise.all([
cowApi.findByFarmNo(farmNo).catch(() => []),
dashboardApi.getFarmEvaluation(farmNo, filterDto).catch(() => null),
user
? breedApi.findByUser(user.pkUserNo).catch(() => [])
: Promise.resolve([]),
])
// 안전하게 데이터 추출
const totalCows = Array.isArray(cows) ? cows.length : 0
const analysisComplete = Array.isArray(cows)
? cows.filter((cow: any) => cow.genomeScore !== null && cow.genomeScore !== undefined).length
: 0
const avgGenomeScore = evaluationData?.genomeScore ?? 0
const breedSaveCount = Array.isArray(breedSaves) ? breedSaves.length : 0
// KPI 데이터 구성
setKpiData([
{
label: "전체 개체 수",
value: totalCows,
change: 0,
changeLabel: `분석 완료: ${analysisComplete}마리`,
icon: <Users className="h-7 w-7" />,
color: "blue",
subtext: `분석 완료 ${analysisComplete}마리`
},
{
label: "유전체 평균",
value: avgGenomeScore.toFixed(1),
change: avgGenomeScore >= 70 ? 4.8 : -1.5,
changeLabel: avgGenomeScore >= 70 ? '육질/육량 형질 우수' : '형질 개선 필요',
icon: <Activity className="h-7 w-7" />,
color: "cyan",
badge: avgGenomeScore >= 70 ? (
<Badge className="badge-gene-positive flex items-center gap-1">
<IconTrendingUp className="w-3 h-3" />
</Badge>
) : avgGenomeScore >= 50 ? (
<Badge className="badge-gene-neutral flex items-center gap-1">
<IconMoodNeutral className="w-3 h-3" />
</Badge>
) : (
<Badge className="badge-gene-negative flex items-center gap-1">
<IconTrendingDown className="w-3 h-3" />
</Badge>
)
},
{
label: "교배계획 저장",
value: breedSaveCount,
change: 0,
changeLabel: '저장된 교배 조합',
icon: <Heart className="h-7 w-7" />,
color: "pink"
}
])
} catch (error) {
console.error('KPI 데이터 로드 실패:', error)
console.error('에러 상세:', error instanceof Error ? error.message : '알 수 없는 에러')
// 에러 시 기본값
setKpiData([
{
label: "전체 개체 수",
value: 0,
change: 0,
changeLabel: "데이터 없음",
icon: <Users className="h-7 w-7" />,
color: "blue"
},
{
label: "유전체 평균",
value: "0.0",
change: 0,
changeLabel: "데이터 없음",
icon: <Activity className="h-7 w-7" />,
color: "cyan"
},
{
label: "교배계획 저장",
value: 0,
change: 0,
changeLabel: "데이터 없음",
icon: <Heart className="h-7 w-7" />,
color: "pink"
}
])
} finally {
setLoading(false)
}
}
fetchData()
}, [farmNo, user, filters.selectedGenes])
if (loading) {
return (
<>
{[1, 2, 3].map((i) => (
<Card key={i} className="bg-slate-50/50 border-0">
<CardContent className="p-4 md:p-5">
<div className="animate-pulse space-y-3">
<div className="flex items-start justify-between">
<div className="h-4 w-24 bg-gray-200 rounded" />
<div className="h-5 w-12 bg-gray-200 rounded-full" />
</div>
<div className="space-y-2">
<div className="h-10 w-16 bg-gray-300 rounded" />
<div className="h-3 w-32 bg-gray-200 rounded" />
</div>
</div>
</CardContent>
</Card>
))}
</>
)
}
const getColorClasses = (color: string) => {
const colors = {
blue: "bg-blue-50 text-blue-700 border-blue-200",
cyan: "bg-cyan-50 text-cyan-700 border-cyan-200",
pink: "bg-pink-50 text-pink-700 border-pink-200",
orange: "bg-orange-50 text-orange-700 border-orange-200"
}
return colors[color as keyof typeof colors] || colors.blue
}
const getTrendIcon = (change: number) => {
if (change > 0) return <TrendingUp className="h-4 w-4" />
if (change < 0) return <TrendingDown className="h-4 w-4" />
return <Minus className="h-4 w-4" />
}
const getTrendColor = (change: number) => {
if (change > 0) return "text-green-600 bg-green-50"
if (change < 0) return "text-red-600 bg-red-50"
return "text-gray-600 bg-gray-50"
}
return (
<>
{kpiData.map((kpi, index) => (
<Card key={index} className="bg-slate-50/50 border-0 hover:bg-slate-100/50 transition-colors">
<CardContent className="p-4 md:p-5">
<div className="flex items-start justify-between mb-3">
<p className="text-sm md:text-sm text-gray-600 font-medium">{kpi.label}</p>
{kpi.badge ? (
<div>{kpi.badge}</div>
) : kpi.change !== 0 ? (
<div className={`flex items-center gap-1 text-xs font-medium ${
kpi.change > 0 ? 'text-green-600' :
kpi.change < 0 ? 'text-red-600' :
'text-gray-600'
}`}>
{getTrendIcon(kpi.change)}
<span>{Math.abs(kpi.change)}%</span>
</div>
) : null}
</div>
<div className="space-y-2">
<div className="flex items-baseline gap-2">
<p className="text-3xl md:text-4xl font-bold tracking-tight">
{kpi.value}
</p>
{index === 0 && <span className="text-sm text-gray-500"></span>}
</div>
{kpi.changeLabel && (
<p className="text-xs md:text-sm text-gray-500 line-clamp-1">{kpi.changeLabel}</p>
)}
</div>
</CardContent>
</Card>
))}
</>
)
}

View File

@@ -0,0 +1,169 @@
'use client'
import { IconTrendingDown, IconTrendingUp, IconMoodNeutral } from "@tabler/icons-react"
import { useEffect, useState } from "react"
import { Badge } from "@/components/ui/badge"
import {
Card,
CardAction,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
CardContent,
} from "@/components/ui/card"
import { dashboardApi } from "@/lib/api"
import { FarmSummaryDto, FarmEvaluationDto } from "@/types/dashboard.types"
interface SectionCardsProps {
farmNo: number
}
export function SectionCards({ farmNo }: SectionCardsProps) {
const [summary, setSummary] = useState<FarmSummaryDto | null>(null)
const [evaluation, setEvaluation] = useState<FarmEvaluationDto | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
const fetchData = async () => {
if (!farmNo) {
console.warn('farmNo가 없습니다.')
setLoading(false)
return
}
try {
// 백엔드 API에서 실제 데이터 조회
const [summaryData, evaluationData] = await Promise.all([
dashboardApi.getFarmSummary(farmNo).catch(err => {
console.error('농장 요약 조회 실패:', err)
return null
}),
dashboardApi.getFarmEvaluation(farmNo).catch(err => {
console.error('농장 평가 조회 실패:', err)
return null
}),
])
setSummary(summaryData)
setEvaluation(evaluationData)
} catch (error) {
console.error('대시보드 데이터 로드 실패:', error)
// API 실패 시 기본값 설정
setSummary({
totalCows: 0,
analysisComplete: 0,
})
setEvaluation(null)
} finally {
setLoading(false)
}
}
fetchData()
}, [farmNo])
if (loading) {
return (
<div className="*:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card dark:*:data-[slot=card]:bg-card grid grid-cols-1 gap-4 px-4 *:data-[slot=card]:bg-gradient-to-t *:data-[slot=card]:shadow-xs lg:px-6 @xl/main:grid-cols-2 @5xl/main:grid-cols-4">
{[1, 2, 3, 4].map((i) => (
<Card key={i} className="@container/card animate-pulse">
<CardHeader>
<CardDescription> ...</CardDescription>
<CardTitle className="text-2xl font-semibold">--</CardTitle>
</CardHeader>
</Card>
))}
</div>
)
}
const totalCows = summary?.totalCows || 0
const analysisComplete = summary?.analysisComplete || 0
// 유전자 보유율 = (육질형 + 육량형) / 2
const avgGeneScore = evaluation?.genePossession
? (evaluation.genePossession.meatQuality.averageRate + evaluation.genePossession.meatQuantity.averageRate) / 2
: 0
const avgGenomeScore = evaluation?.genomeScore || 0
return (
<div className="grid grid-cols-1 gap-5 px-4 lg:px-6 sm:grid-cols-2 xl:grid-cols-4">
<Card className="shadow-sm hover:shadow-md transition-all duration-200 border-l-4 border-l-blue-500">
<CardHeader className="pb-3">
<CardDescription className="text-xs font-semibold uppercase tracking-wide"> </CardDescription>
<CardTitle className="text-3xl font-bold text-foreground mt-2">{totalCows}</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-2 text-sm">
<div className="h-2 w-2 rounded-full bg-green-500"></div>
<p className="text-muted-foreground">
<span className="font-semibold text-foreground">{analysisComplete}</span>
</p>
</div>
</CardContent>
</Card>
<Card className="shadow-sm hover:shadow-md transition-all duration-200 border-l-4 border-l-amber-500">
<CardHeader className="pb-3">
<CardDescription className="text-xs font-semibold uppercase tracking-wide"> </CardDescription>
<CardTitle className="text-3xl font-bold text-foreground mt-2">{avgGeneScore.toFixed(1)}%</CardTitle>
</CardHeader>
<CardContent>
<Badge className={`${avgGeneScore >= 70 ? 'badge-gene-positive' : avgGeneScore >= 50 ? 'badge-gene-neutral' : 'badge-gene-negative'} flex items-center gap-1 w-fit`}>
{avgGeneScore >= 70 ? <IconTrendingUp className="w-3 h-3" /> : avgGeneScore >= 50 ? <IconMoodNeutral className="w-3 h-3" /> : <IconTrendingDown className="w-3 h-3" />}
{avgGeneScore >= 70 ? '우수' : avgGeneScore >= 50 ? '보통' : '개선필요'}
</Badge>
<p className="text-xs text-muted-foreground mt-2">
{avgGeneScore >= 70 ? '우량 유전자 보유율 높음' : '유전자 개선 필요'}
</p>
</CardContent>
</Card>
<Card className="shadow-sm hover:shadow-md transition-all duration-200 border-l-4 border-l-cyan-500">
<CardHeader className="pb-3">
<CardDescription className="text-xs font-semibold uppercase tracking-wide"> </CardDescription>
<CardTitle className="text-3xl font-bold text-foreground mt-2">{avgGenomeScore.toFixed(1)}</CardTitle>
</CardHeader>
<CardContent>
<Badge className={`${avgGenomeScore >= 70 ? 'badge-gene-positive' : avgGenomeScore >= 50 ? 'badge-gene-neutral' : 'badge-gene-negative'} flex items-center gap-1 w-fit`}>
{avgGenomeScore >= 70 ? <IconTrendingUp className="w-3 h-3" /> : avgGenomeScore >= 50 ? <IconMoodNeutral className="w-3 h-3" /> : <IconTrendingDown className="w-3 h-3" />}
{avgGenomeScore >= 70 ? '우수' : avgGenomeScore >= 50 ? '보통' : '개선필요'}
</Badge>
<p className="text-xs text-muted-foreground mt-2">
{avgGenomeScore >= 70 ? '육질/육량 형질 우수' : '형질 개선 필요'}
</p>
</CardContent>
</Card>
<Card className="@container/card">
<CardHeader>
<CardDescription> </CardDescription>
<CardTitle className={`text-2xl font-semibold ${
(evaluation?.genomeScore || 0) >= 70 ? 'text-green-600' :
(evaluation?.genomeScore || 0) >= 60 ? 'text-blue-600' :
(evaluation?.genomeScore || 0) >= 40 ? 'text-yellow-600' :
'text-red-600'
}`}>
{evaluation?.genomeScore?.toFixed(1) || '-'}
</CardTitle>
</CardHeader>
<CardContent>
<Badge className={
(evaluation?.genomeScore || 0) >= 70 ? 'bg-green-100 text-green-800' :
(evaluation?.genomeScore || 0) >= 60 ? 'bg-blue-100 text-blue-800' :
(evaluation?.genomeScore || 0) >= 40 ? 'bg-yellow-100 text-yellow-800' :
'bg-red-100 text-red-800'
}>
{(evaluation?.genomeScore || 0) >= 60 ? <IconTrendingUp className="w-4 h-4" /> : <IconMoodNeutral className="w-4 h-4" />}
{evaluation?.genomeRank || '-'}
</Badge>
<p className="text-xs text-muted-foreground mt-2">
( )
</p>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,414 @@
'use client'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { useEffect, useState } from "react"
import { useAnalysisYear } from "@/contexts/AnalysisYearContext"
import { useGlobalFilter } from "@/contexts/GlobalFilterContext"
import { Trophy, AlertTriangle, Sparkles, Star, AlertCircle, Lightbulb } from "lucide-react"
import { dashboardApi } from "@/lib/api/dashboard.api"
import { toast } from "sonner"
interface CattleItem {
rank: number
cattleId: string
name: string
score: number
reason: string
scoreUnit?: string // 점수 단위 (점, 마리 등)
}
interface Top3ListsProps {
farmNo: number | null
mode?: 'full' | 'compact' | 'cull-only' | 'recommend-only'
}
export function Top3Lists({ farmNo, mode = 'full' }: Top3ListsProps) {
const { selectedYear } = useAnalysisYear()
const { filters } = useGlobalFilter()
const [excellentList, setExcellentList] = useState<CattleItem[]>([])
const [cullingList, setCullingList] = useState<CattleItem[]>([])
const [recommendList, setRecommendList] = useState<CattleItem[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
const fetchData = async () => {
if (!farmNo) {
setLoading(false)
return
}
setLoading(true)
try {
// 필터 조건 생성
const filterDto = {
targetGenes: filters.selectedGenes,
limit: 3, // Top 3만 가져오기
}
// 병렬로 API 호출
const [excellentData, cullData, kpnData] = await Promise.all([
dashboardApi.getExcellentCows(farmNo, filterDto),
dashboardApi.getCullCows(farmNo, filterDto),
dashboardApi.getKpnRecommendationAggregation(farmNo, filterDto),
])
// 우수개체 데이터 변환
const excellentList: CattleItem[] = (excellentData || []).map((item: any, index: number) => ({
rank: index + 1,
cattleId: item.cowNo || '없음',
name: item.cowName || item.cowNo || '정보 없음', // 이름이 없으면 개체번호 사용
score: Math.round(item.overallScore || 0),
scoreUnit: '점',
reason: item.reason || '정보 없음',
}))
// 도태개체 데이터 변환
const cullList: CattleItem[] = (cullData || []).map((item: any, index: number) => ({
rank: index + 1,
cattleId: item.cowNo || '없음',
name: item.cowName || item.cowNo || '정보 없음', // 이름이 없으면 개체번호 사용
score: Math.round(item.overallScore || 0),
scoreUnit: '점',
reason: item.reason || '정보 없음',
}))
// KPN 추천 데이터 변환 (상위 3개)
const recommendList: CattleItem[] = ((kpnData?.kpnAggregations || []) as any[])
.slice(0, 3)
.map((item: any, index: number) => ({
rank: index + 1,
cattleId: item.kpnNumber || '없음',
name: item.kpnName || '이름 없음',
score: item.recommendedCowCount || 0,
scoreUnit: '마리',
reason: `평균 매칭점수 ${Math.round(item.avgMatchingScore || 0)}`,
}))
setExcellentList(excellentList)
setCullingList(cullList)
setRecommendList(recommendList)
} catch (error: any) {
toast.error(`데이터를 불러오는데 실패했습니다: ${error?.message || '알 수 없는 오류'}`)
// 에러 시 빈 배열 설정
setExcellentList([])
setCullingList([])
setRecommendList([])
} finally {
setLoading(false)
}
}
fetchData()
}, [selectedYear, filters, farmNo])
if (loading) {
return (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-3 md:gap-4">
{[1, 2, 3].map((i) => (
<Card key={i} className="border-gray-200">
<CardHeader className="pb-2 md:pb-3">
<CardTitle className="text-xs md:text-sm font-semibold"> ...</CardTitle>
</CardHeader>
<CardContent className="pt-0 pb-2 md:pb-3">
<div className="h-[150px] flex items-center justify-center">
<div className="animate-pulse space-y-2 w-full">
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
<div className="h-4 bg-gray-200 rounded w-1/2"></div>
<div className="h-4 bg-gray-200 rounded w-5/6"></div>
</div>
</div>
</CardContent>
</Card>
))}
</div>
)
}
if (!farmNo) {
return (
<div className="px-4 lg:px-6">
<h2 className="text-xl font-bold mb-4"> </h2>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{[1, 2, 3].map((i) => (
<Card key={i}>
<CardHeader>
<CardTitle>
{i === 1 ? '우수개체 Top3' : i === 2 ? '도태대상 Top3' : 'KPN추천 Top3'}
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[200px] flex items-center justify-center">
<div className="text-center">
<p className="text-muted-foreground mb-2"> </p>
<p className="text-sm text-muted-foreground"> </p>
</div>
</div>
</CardContent>
</Card>
))}
</div>
</div>
)
}
const renderListCard = (
title: string,
description: string,
icon: React.ReactNode,
items: CattleItem[],
variant: 'excellent' | 'culling' | 'recommend'
) => {
const colorSchemes = {
excellent: {
badge: 'bg-green-600 text-white'
},
culling: {
badge: 'bg-red-600 text-white'
},
recommend: {
badge: 'bg-blue-600 text-white'
}
}
const scheme = colorSchemes[variant]
return (
<Card>
<CardContent className="pt-4">
<div className="space-y-2.5">
{items.map((item, index) => (
<div
key={item.cattleId}
className="relative p-3 rounded-lg border border-gray-200 bg-white hover:shadow-sm transition-shadow"
>
{/* 순위 배지 */}
<div className="absolute -top-1.5 -right-1.5">
<div className={`w-6 h-6 rounded-full ${scheme.badge} flex items-center justify-center text-[10px] font-bold shadow-sm`}>
{item.rank}
</div>
</div>
<div className="flex items-start justify-between mb-2">
<div className="flex-1">
<p className="font-semibold text-sm text-foreground mb-0.5">{item.name}</p>
<p className="text-[10px] text-muted-foreground font-mono">{item.cattleId}</p>
</div>
<div className="text-right ml-2">
<p className="text-xl font-bold text-foreground">{item.score}</p>
<p className="text-[10px] text-muted-foreground">{item.scoreUnit || '점'}</p>
</div>
</div>
<div className="pt-2 border-t border-gray-100">
<p className="text-xs text-muted-foreground">{item.reason}</p>
</div>
</div>
))}
</div>
{/* 요약 */}
<div className="mt-4 pt-3 border-t border-border">
<div className="text-center space-y-0.5">
<p className="text-xs font-medium text-foreground">
{variant === 'excellent'
? '농장 내 최상위 개체'
: variant === 'culling'
? '개선 또는 도태 권장'
: '최적 교배 추천'}
</p>
<p className="text-[10px] text-muted-foreground">
{filters.analysisIndex === 'GENE' ? '유전자 기반 분석' : '유전능력 기반 분석'}
</p>
</div>
</div>
</CardContent>
</Card>
)
}
// compact 모드: 우수개체만 표시
if (mode === 'compact') {
return renderListCard(
'우수개체 Top3',
'농장 내 상위 개체',
<Trophy className="h-5 w-5 text-green-600" />,
excellentList,
'excellent'
)
}
// cull-only 모드: 도태대상만 표시
if (mode === 'cull-only') {
return renderListCard(
'도태대상 Top3',
'개선 필요 개체',
<AlertTriangle className="h-5 w-5 text-red-600" />,
cullingList,
'culling'
)
}
// recommend-only 모드: KPN추천만 표시
if (mode === 'recommend-only') {
return renderListCard(
'KPN추천 Top3',
'최적 씨수소 추천',
<Sparkles className="h-5 w-5 text-blue-600" />,
recommendList,
'recommend'
)
}
// Vercel 스타일 리스트 아이템 렌더링
const renderListItem = (item: CattleItem, variant: 'excellent' | 'culling' | 'recommend') => {
const colorSchemes = {
excellent: {
icon: <Trophy className="h-4 w-4 text-green-600" />,
scoreColor: 'text-green-600',
dotColor: 'bg-green-500'
},
culling: {
icon: <AlertTriangle className="h-4 w-4 text-red-600" />,
scoreColor: 'text-red-600',
dotColor: 'bg-red-500'
},
recommend: {
icon: <Sparkles className="h-4 w-4 text-blue-600" />,
scoreColor: 'text-blue-600',
dotColor: 'bg-blue-500'
}
}
const scheme = colorSchemes[variant]
return (
<div className="flex items-center justify-between py-2 md:py-2.5 border-b border-gray-100 last:border-0 hover:bg-gray-50 transition-colors px-1 md:px-1.5 -mx-1 md:-mx-1.5 rounded">
<div className="flex items-center gap-2 md:gap-2.5 flex-1 min-w-0">
<div className={`w-1 h-1 md:w-1.5 md:h-1.5 rounded-full ${scheme.dotColor} flex-shrink-0`}></div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1 md:gap-1.5">
<p className="text-xs md:text-sm font-medium text-foreground truncate">{item.name}</p>
<span className="text-[9px] md:text-[10px] text-gray-400 font-mono flex-shrink-0">{item.cattleId}</span>
</div>
<p className="text-[9px] md:text-[10px] text-gray-500 mt-0.5 line-clamp-1">{item.reason}</p>
</div>
</div>
<div className="flex items-center gap-1 md:gap-1.5 flex-shrink-0 ml-2">
<p className={`text-sm md:text-base font-semibold ${scheme.scoreColor}`}>{item.score}</p>
<span className="text-[9px] md:text-[10px] text-gray-400">{item.scoreUnit || '점'}</span>
</div>
</div>
)
}
// full 모드: Vercel 스타일 리스트로 표시
return (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-3 md:gap-4">
{/* 우수개체 섹션 */}
<Card className="border-gray-200 shadow-none hover:border-black hover:shadow-lg transition-all duration-200">
<CardHeader className="pb-2 md:pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5 md:gap-2">
<Trophy className="h-3 w-3 md:h-3.5 md:w-3.5 text-green-600" />
<CardTitle className="text-xs md:text-sm font-semibold"> Top3</CardTitle>
</div>
<svg className="w-3 h-3 md:w-3.5 md:h-3.5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
</CardHeader>
<CardContent className="pt-0 pb-2 md:pb-3">
{excellentList.length > 0 ? (
<div className="space-y-0">
{excellentList.map((item) => (
<div key={item.cattleId}>
{renderListItem(item, 'excellent')}
</div>
))}
</div>
) : (
<div className="h-[150px] flex items-center justify-center">
<div className="text-center">
<Trophy className="h-8 w-8 text-gray-300 mx-auto mb-2" />
<p className="text-xs text-muted-foreground"> </p>
<p className="text-[10px] text-gray-400 mt-1"> </p>
</div>
</div>
)}
</CardContent>
</Card>
{/* 도태대상 섹션 */}
<Card className="border-gray-200 shadow-none hover:border-black hover:shadow-lg transition-all duration-200">
<CardHeader className="pb-2 md:pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5 md:gap-2">
<AlertTriangle className="h-3 w-3 md:h-3.5 md:w-3.5 text-red-600" />
<CardTitle className="text-xs md:text-sm font-semibold"> Top3</CardTitle>
</div>
<svg className="w-3 h-3 md:w-3.5 md:h-3.5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
</CardHeader>
<CardContent className="pt-0 pb-2 md:pb-3">
{cullingList.length > 0 ? (
<div className="space-y-0">
{cullingList.map((item) => (
<div key={item.cattleId}>
{renderListItem(item, 'culling')}
</div>
))}
</div>
) : (
<div className="h-[150px] flex items-center justify-center">
<div className="text-center">
<AlertTriangle className="h-8 w-8 text-gray-300 mx-auto mb-2" />
<p className="text-xs text-muted-foreground"> </p>
<p className="text-[10px] text-gray-400 mt-1"> </p>
</div>
</div>
)}
</CardContent>
</Card>
{/* KPN 추천 섹션 */}
<Card className="border-gray-200 shadow-none hover:border-black hover:shadow-lg transition-all duration-200">
<CardHeader className="pb-2 md:pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5 md:gap-2">
<Sparkles className="h-3 w-3 md:h-3.5 md:w-3.5 text-blue-600" />
<CardTitle className="text-xs md:text-sm font-semibold">KPN Top3</CardTitle>
</div>
<svg className="w-3 h-3 md:w-3.5 md:h-3.5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
</CardHeader>
<CardContent className="pt-0 pb-2 md:pb-3">
{recommendList.length > 0 ? (
<div className="space-y-0">
{recommendList.map((item) => (
<div key={item.cattleId}>
{renderListItem(item, 'recommend')}
</div>
))}
</div>
) : (
<div className="h-[150px] flex items-center justify-center">
<div className="text-center">
<Sparkles className="h-8 w-8 text-gray-300 mx-auto mb-2" />
<p className="text-xs text-muted-foreground"> </p>
<p className="text-[10px] text-gray-400 mt-1">KPN </p>
</div>
</div>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,235 @@
'use client'
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { User, CheckCircle2, BarChart3 } from "lucide-react"
import { CowDetail } from "@/types/cow.types"
import { GenomeTrait as GenomeTraitType } from "@/types/genome.types"
import { ResponsiveContainer, RadarChart, PolarGrid, PolarAngleAxis, PolarRadiusAxis, Radar, Legend, Tooltip as RechartsTooltip } from 'recharts'
interface CowCompareModalProps {
isOpen: boolean
onClose: () => void
compareCowsData: { cow: CowDetail; genome: GenomeTraitType[] }[]
transformGenomeData: (genomeData: GenomeTraitType[]) => any[]
CATEGORIES: string[]
TRAIT_COLORS: string[]
}
export function CowCompareModal({
isOpen,
onClose,
compareCowsData,
transformGenomeData,
CATEGORIES,
TRAIT_COLORS
}: CowCompareModalProps) {
if (!isOpen || compareCowsData.length === 0) return null
return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white w-full max-w-6xl max-h-[90vh] rounded-lg overflow-hidden flex flex-col">
{/* 헤더 */}
<div className="p-4 border-b border-border flex items-center justify-between bg-primary/5">
<div>
<h2 className="text-lg font-bold flex items-center gap-2">
<BarChart3 className="w-5 h-5 text-primary" />
</h2>
<p className="text-sm text-muted-foreground mt-1">
{compareCowsData.length}
</p>
</div>
<Button onClick={onClose} variant="ghost" size="sm">
</Button>
</div>
{/* 비교 내용 */}
<div className="flex-1 overflow-y-auto p-4">
{/* 개체 카드들 */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
{compareCowsData.map((cowData, idx) => {
const genomeTraits = transformGenomeData(cowData.genome)
const avgBreedVal = genomeTraits.length > 0
? genomeTraits.reduce((sum: number, t: any) => sum + t.breedVal, 0) / genomeTraits.length
: 0
const avgPercentile = genomeTraits.length > 0
? genomeTraits.reduce((sum: number, t: any) => sum + t.percentile, 0) / genomeTraits.length
: 0
return (
<Card key={cowData.cow.pkCowNo} className={idx === 0 ? 'border-2 border-primary' : ''}>
<CardHeader className={idx === 0 ? 'bg-primary/5' : ''}>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">{cowData.cow.cowId || cowData.cow.pkCowNo}</CardTitle>
{cowData.cow.cowId && (
<CardDescription>{cowData.cow.cowId}</CardDescription>
)}
</div>
{idx === 0 && (
<Badge className="bg-primary text-white"> </Badge>
)}
</div>
</CardHeader>
<CardContent className="pt-4">
<div className="space-y-3">
<div className="text-center p-3 bg-muted/30 rounded-lg">
<div className="text-xs text-muted-foreground mb-1"> </div>
<div className="text-2xl font-bold text-primary">
{avgBreedVal > 0 ? '+' : ''}{avgBreedVal.toFixed(2)}σ
</div>
<div className="text-xs text-muted-foreground mt-1">
{(100 - avgPercentile).toFixed(1)}%
</div>
</div>
<div className="text-xs space-y-1">
<div className="flex justify-between">
<span className="text-muted-foreground"></span>
<span className="font-semibold">
{cowData.cow.cowBirthDt
? new Date(cowData.cow.cowBirthDt).toLocaleDateString('ko-KR')
: 'N/A'}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground"></span>
<span className="font-semibold">
{cowData.cow.age ? `${cowData.cow.age}` : 'N/A'}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground"> </span>
<span className="font-semibold">{genomeTraits.length}</span>
</div>
</div>
</div>
</CardContent>
</Card>
)
})}
</div>
{/* 카테고리별 비교 차트 */}
<Card>
<CardHeader>
<CardTitle> </CardTitle>
<CardDescription> </CardDescription>
</CardHeader>
<CardContent>
<div className="h-96">
<ResponsiveContainer width="100%" height="100%">
<RadarChart>
<PolarGrid stroke="#e2e8f0" />
<PolarAngleAxis
dataKey="category"
tick={{ fill: '#64748b', fontSize: 12 }}
/>
<PolarRadiusAxis
angle={90}
domain={[-1, 2]}
tick={{ fill: '#64748b', fontSize: 10 }}
/>
<RechartsTooltip />
<Legend />
{compareCowsData.map((cowData, idx) => {
const genomeTraits = transformGenomeData(cowData.genome)
const categoryData = CATEGORIES.map(cat => {
const traits = genomeTraits.filter((t: any) => t.category === cat)
const avgBreedVal = traits.length > 0
? traits.reduce((sum: number, t: any) => sum + t.breedVal, 0) / traits.length
: 0
return {
category: cat,
value: avgBreedVal
}
})
const cowLabel = cowData.cow.cowId || String(cowData.cow.pkCowNo)
return (
<Radar
key={cowData.cow.pkCowNo}
name={idx === 0 ? `${cowLabel} (현재)` : cowLabel}
dataKey="value"
stroke={TRAIT_COLORS[idx % TRAIT_COLORS.length]}
fill={TRAIT_COLORS[idx % TRAIT_COLORS.length]}
fillOpacity={idx === 0 ? 0.6 : 0.3}
strokeWidth={idx === 0 ? 3 : 2}
/>
)
})}
</RadarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
{/* 형질별 비교 테이블 */}
<Card className="mt-4">
<CardHeader>
<CardTitle> (Top 10)</CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-muted/50 border-b-2">
<tr>
<th className="px-3 py-2 text-left font-semibold sticky left-0 bg-muted/50"></th>
{compareCowsData.map((cowData, idx) => {
const cowLabel = cowData.cow.cowId || String(cowData.cow.pkCowNo)
return (
<th key={cowData.cow.pkCowNo} className="px-3 py-2 text-center font-semibold">
{idx === 0 ? `${cowLabel}\n(현재)` : cowLabel}
</th>
)
})}
</tr>
</thead>
<tbody className="divide-y">
{transformGenomeData(compareCowsData[0].genome).slice(0, 10).map((trait: any) => (
<tr key={trait.id} className="hover:bg-muted/30">
<td className="px-3 py-2 font-medium sticky left-0 bg-white">
{trait.name}
</td>
{compareCowsData.map((cowData) => {
const genomeTraits = transformGenomeData(cowData.genome)
const matchTrait = genomeTraits.find((t: any) => t.name === trait.name)
return (
<td key={cowData.cow.pkCowNo} className="px-3 py-2 text-center">
{matchTrait ? (
<div>
<div className={`font-bold ${matchTrait.breedVal > 0 ? 'text-primary' : 'text-muted-foreground'}`}>
{matchTrait.breedVal > 0 ? '+' : ''}{matchTrait.breedVal.toFixed(2)}σ
</div>
<div className="text-xs text-muted-foreground">
{matchTrait.percentile.toFixed(1)}%
</div>
</div>
) : (
<span className="text-muted-foreground">N/A</span>
)}
</td>
)
})}
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
</div>
{/* 푸터 */}
<div className="p-4 border-t border-border bg-muted/30 flex justify-end">
<Button onClick={onClose}>
</Button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,159 @@
'use client'
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { User, CheckCircle2 } from "lucide-react"
import { CowDetail } from "@/types/cow.types"
interface CowSelectSheetProps {
isOpen: boolean
onClose: () => void
farmCows: CowDetail[]
selectedCowsForCompare: number[]
toggleCowForCompare: (cowNo: number) => void
onCompare: () => void
onClearSelection: () => void
}
export function CowSelectSheet({
isOpen,
onClose,
farmCows,
selectedCowsForCompare,
toggleCowForCompare,
onCompare,
onClearSelection
}: CowSelectSheetProps) {
if (!isOpen) return null
return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center md:justify-center">
<div className="bg-white w-full md:max-w-3xl md:max-h-[80vh] md:rounded-lg overflow-hidden flex flex-col max-h-[90vh] rounded-t-2xl md:rounded-2xl">
{/* 헤더 */}
<div className="p-4 border-b border-border flex items-center justify-between bg-primary/5">
<div>
<h2 className="text-lg font-bold flex items-center gap-2">
<User className="w-5 h-5 text-primary" />
</h2>
<p className="text-sm text-muted-foreground mt-1">
({selectedCowsForCompare.length}/{farmCows.length})
</p>
</div>
<Button onClick={onClose} variant="ghost" size="sm">
</Button>
</div>
{/* 개체 목록 */}
<div className="flex-1 overflow-y-auto p-4">
{farmCows.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<User className="w-12 h-12 mx-auto mb-4 opacity-50" />
<p> </p>
</div>
) : (
<div className="space-y-2">
{farmCows.map((farmCow) => {
const isSelected = selectedCowsForCompare.includes(farmCow.pkCowNo)
return (
<div
key={farmCow.pkCowNo}
onClick={() => toggleCowForCompare(farmCow.pkCowNo)}
className={`p-4 rounded-lg border-2 cursor-pointer transition-all ${
isSelected
? 'border-primary bg-primary/5'
: 'border-border hover:border-primary/50 hover:bg-muted/50'
}`}
>
<div className="flex items-start gap-3">
{/* 체크박스 */}
<div className="flex items-center pt-1">
<div
className={`w-5 h-5 rounded border-2 flex items-center justify-center ${
isSelected
? 'bg-primary border-primary'
: 'border-muted-foreground'
}`}
>
{isSelected && (
<CheckCircle2 className="w-4 h-4 text-white" />
)}
</div>
</div>
{/* 개체 정보 */}
<div className="flex-1">
<div className="flex items-start justify-between mb-2">
<div>
<h4 className="font-bold text-foreground">{farmCow.cowId || farmCow.pkCowNo}</h4>
{farmCow.cowId && (
<p className="text-sm text-muted-foreground">{farmCow.cowId}</p>
)}
</div>
</div>
{/* 상세 정보 */}
<div className="grid grid-cols-3 gap-2 text-xs mt-2">
<div>
<div className="text-muted-foreground"></div>
<div className="font-semibold text-foreground">
{farmCow.cowBirthDt
? new Date(farmCow.cowBirthDt).toLocaleDateString('ko-KR', {
year: '2-digit',
month: '2-digit',
day: '2-digit'
})
: 'N/A'}
</div>
</div>
<div>
<div className="text-muted-foreground"></div>
<div className="font-semibold text-foreground">
{farmCow.age ? `${farmCow.age}` : 'N/A'}
</div>
</div>
<div>
<div className="text-muted-foreground"></div>
<div className="font-semibold text-foreground">
{farmCow.cowSex === 'F' ? '암' : farmCow.cowSex === 'M' ? '수' : 'N/A'}
</div>
</div>
</div>
</div>
</div>
</div>
)
})}
</div>
)}
</div>
{/* 푸터 */}
<div className="p-4 border-t border-border bg-muted/30 flex items-center justify-between">
<div className="text-sm text-muted-foreground">
{selectedCowsForCompare.length}
</div>
<div className="flex gap-2">
<Button
onClick={onClearSelection}
variant="ghost"
size="sm"
disabled={selectedCowsForCompare.length === 0}
>
</Button>
<Button
onClick={onCompare}
className="gap-2"
disabled={selectedCowsForCompare.length === 0}
>
<CheckCircle2 className="w-4 h-4" />
</Button>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,300 @@
'use client'
import { useState, useMemo, useEffect } from "react"
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Checkbox } from "@/components/ui/checkbox"
import { Label } from "@/components/ui/label"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Search, Loader2 } from "lucide-react"
import { geneApi } from "@/lib/api/gene.api"
/**
* 마커 데이터 타입 (API에서 받아오는 형식)
*/
interface MarkerData {
pkMarkerNo: number
markerNm: string
markerDesc: string
markerTypeCd: string
relatedTrait: string
favorableAllele: string
useYn: string
markerTypeInfo?: {
pkTypeCd: string
typeNm: string
typeDesc: string
}
}
/**
* 유전자 필터에서 사용할 간소화된 타입
*/
interface GeneOption {
name: string
description: string
type: 'QTY' | 'QLT'
relatedTrait: string
}
interface GeneFilterModalProps {
open: boolean
onOpenChange: (open: boolean) => void
selectedGenes: string[]
onConfirm: (genes: string[]) => void
}
export function GeneFilterModal({ open, onOpenChange, selectedGenes, onConfirm }: GeneFilterModalProps) {
const [tempSelectedGenes, setTempSelectedGenes] = useState<string[]>(selectedGenes)
const [searchQuery, setSearchQuery] = useState('')
const [sortBy, setSortBy] = useState<'name' | 'type'>('name')
const [allMarkers, setAllMarkers] = useState<GeneOption[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
// API에서 마커 목록 가져오기
useEffect(() => {
if (open) {
fetchMarkers()
}
}, [open])
const fetchMarkers = async () => {
try {
setLoading(true)
setError(null)
const markers = await geneApi.getAllMarkers() as unknown as MarkerData[]
// API 데이터를 GeneOption 형식으로 변환
const geneOptions: GeneOption[] = markers.map(marker => ({
name: marker.markerNm,
description: marker.relatedTrait || marker.markerDesc || '',
type: marker.markerTypeCd as 'QTY' | 'QLT',
relatedTrait: marker.relatedTrait || ''
}))
setAllMarkers(geneOptions)
} catch (err) {
console.error('Failed to fetch markers:', err)
setError('유전자 목록을 불러오는데 실패했습니다.')
} finally {
setLoading(false)
}
}
// 육량형/육질형 필터링
const quantityGenes = useMemo(() => {
return allMarkers.filter(g => g.type === 'QTY').sort((a, b) => a.name.localeCompare(b.name))
}, [allMarkers])
const qualityGenes = useMemo(() => {
return allMarkers.filter(g => g.type === 'QLT').sort((a, b) => a.name.localeCompare(b.name))
}, [allMarkers])
// 전체 유전자 목록 (정렬)
const allGenes = useMemo(() => {
return [...allMarkers].sort((a, b) => {
if (sortBy === 'type') {
if (a.type !== b.type) {
return a.type.localeCompare(b.type)
}
}
return a.name.localeCompare(b.name)
})
}, [allMarkers, sortBy])
const filteredGenes = useMemo(() => {
if (!searchQuery) return allGenes
return allGenes.filter(gene =>
gene.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
gene.description.toLowerCase().includes(searchQuery.toLowerCase())
)
}, [allGenes, searchQuery])
const toggleGene = (geneName: string) => {
setTempSelectedGenes(prev =>
prev.includes(geneName)
? prev.filter(g => g !== geneName)
: [...prev, geneName]
)
}
const handleConfirm = () => {
onConfirm(tempSelectedGenes)
onOpenChange(false)
}
const handleCancel = () => {
setTempSelectedGenes(selectedGenes)
onOpenChange(false)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl max-h-[80vh]">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription>
. .
</DialogDescription>
</DialogHeader>
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<span className="ml-2"> ...</span>
</div>
) : error ? (
<div className="text-center py-8 text-destructive">
<p>{error}</p>
<Button variant="outline" className="mt-4" onClick={fetchMarkers}>
</Button>
</div>
) : (
<Tabs defaultValue="quick" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="quick"> ({allMarkers.length})</TabsTrigger>
<TabsTrigger value="all"> </TabsTrigger>
</TabsList>
<TabsContent value="quick" className="space-y-4">
<Tabs defaultValue="quantity" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="quantity"> ({quantityGenes.length})</TabsTrigger>
<TabsTrigger value="quality"> ({qualityGenes.length})</TabsTrigger>
</TabsList>
<TabsContent value="quantity">
<ScrollArea className="h-[300px] w-full rounded-md border p-4">
<div className="space-y-3">
{quantityGenes.length === 0 ? (
<p className="text-center text-muted-foreground py-8">
.
</p>
) : (
quantityGenes.map((gene) => (
<div key={gene.name} className="flex items-start space-x-3">
<Checkbox
id={`quick-${gene.name}`}
checked={tempSelectedGenes.includes(gene.name)}
onCheckedChange={() => toggleGene(gene.name)}
/>
<div className="flex-1">
<Label
htmlFor={`quick-${gene.name}`}
className="text-sm font-medium cursor-pointer"
>
{gene.name}
</Label>
<p className="text-xs text-muted-foreground">{gene.description}</p>
</div>
</div>
))
)}
</div>
</ScrollArea>
</TabsContent>
<TabsContent value="quality">
<ScrollArea className="h-[300px] w-full rounded-md border p-4">
<div className="space-y-3">
{qualityGenes.length === 0 ? (
<p className="text-center text-muted-foreground py-8">
.
</p>
) : (
qualityGenes.map((gene) => (
<div key={gene.name} className="flex items-start space-x-3">
<Checkbox
id={`quick-${gene.name}`}
checked={tempSelectedGenes.includes(gene.name)}
onCheckedChange={() => toggleGene(gene.name)}
/>
<div className="flex-1">
<Label
htmlFor={`quick-${gene.name}`}
className="text-sm font-medium cursor-pointer"
>
{gene.name}
</Label>
<p className="text-xs text-muted-foreground">{gene.description}</p>
</div>
</div>
))
)}
</div>
</ScrollArea>
</TabsContent>
</Tabs>
</TabsContent>
<TabsContent value="all" className="space-y-4">
<div className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
<Input
placeholder="유전자명 또는 설명 검색..."
className="pl-9"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<Button
variant="outline"
onClick={() => setSortBy(sortBy === 'name' ? 'type' : 'name')}
>
{sortBy === 'type' ? '타입순' : '이름순'}
</Button>
</div>
<ScrollArea className="h-[300px] w-full rounded-md border p-4">
<div className="space-y-3">
{filteredGenes.length === 0 ? (
<p className="text-center text-muted-foreground py-8">
.
</p>
) : (
filteredGenes.map((gene) => (
<div key={gene.name} className="flex items-start space-x-3">
<Checkbox
id={`all-${gene.name}`}
checked={tempSelectedGenes.includes(gene.name)}
onCheckedChange={() => toggleGene(gene.name)}
/>
<div className="flex-1">
<Label
htmlFor={`all-${gene.name}`}
className="text-sm font-medium cursor-pointer"
>
{gene.name} <span className="text-xs text-muted-foreground">({gene.type === 'QTY' ? '육량형' : '육질형'})</span>
</Label>
<p className="text-xs text-muted-foreground">{gene.description}</p>
</div>
</div>
))
)}
</div>
</ScrollArea>
</TabsContent>
</Tabs>
)}
<div className="text-sm text-muted-foreground">
: {tempSelectedGenes.length}
</div>
<DialogFooter>
<Button variant="outline" onClick={handleCancel}>
</Button>
<Button onClick={handleConfirm}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,206 @@
'use client'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { useEffect, useState } from "react"
import { useAnalysisYear } from "@/contexts/AnalysisYearContext"
import { useGlobalFilter } from "@/contexts/GlobalFilterContext"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Filter, ChevronDown, ChevronUp } from "lucide-react"
interface GeneData {
geneName: string
geneType: '육량' | '육질' // 유전자 분류
farmRate: number // 우리 농장 우량형(AA) 보유율
regionAvgRate: number // 지역 평균
}
interface GenePossessionStatusProps {
farmNo: number | null
}
export function GenePossessionStatus({ farmNo }: GenePossessionStatusProps) {
const { selectedYear } = useAnalysisYear()
const { filters } = useGlobalFilter()
const [allGenes, setAllGenes] = useState<GeneData[]>([])
const [loading, setLoading] = useState(true)
const [isExpanded, setIsExpanded] = useState(false)
// 선택된 유전자 확인
const selectedGenes = filters.selectedGenes || []
const hasFilter = selectedGenes.length > 0
useEffect(() => {
const fetchData = async () => {
setLoading(true)
// TODO: 백엔드 API 연동 시 실제 데이터 fetch
// 현재는 목업 데이터 사용 (전체 유전자 리스트)
const mockAllGenes: GeneData[] = [
// 육량 관련
{ geneName: 'PLAG1', geneType: '육량', farmRate: 85, regionAvgRate: 72 },
{ geneName: 'NCAPG', geneType: '육량', farmRate: 82, regionAvgRate: 75 },
{ geneName: 'LCORL', geneType: '육량', farmRate: 78, regionAvgRate: 68 },
{ geneName: 'LAP3', geneType: '육량', farmRate: 65, regionAvgRate: 58 },
// 육질 관련
{ geneName: 'FABP4', geneType: '육질', farmRate: 88, regionAvgRate: 70 },
{ geneName: 'SCD', geneType: '육질', farmRate: 80, regionAvgRate: 72 },
{ geneName: 'DGAT1', geneType: '육질', farmRate: 75, regionAvgRate: 65 },
{ geneName: 'FASN', geneType: '육질', farmRate: 70, regionAvgRate: 62 },
{ geneName: 'CAPN1', geneType: '육질', farmRate: 82, regionAvgRate: 68 },
{ geneName: 'CAST', geneType: '육질', farmRate: 77, regionAvgRate: 64 },
]
// 선택된 유전자 중 목업 데이터에 없는 유전자가 있다면 추가
if (selectedGenes.length > 0) {
selectedGenes.forEach(geneName => {
if (!mockAllGenes.find(g => g.geneName === geneName)) {
// 선택된 유전자가 목업 데이터에 없으면 기본값으로 추가
mockAllGenes.push({
geneName: geneName,
geneType: geneName.includes('PLAG') || geneName.includes('NCAPG') || geneName.includes('LCORL') || geneName.includes('LAP') ? '육량' : '육질',
farmRate: Math.floor(Math.random() * 30) + 60, // 60-90 사이 랜덤값
regionAvgRate: Math.floor(Math.random() * 20) + 55, // 55-75 사이 랜덤값
})
}
})
}
setAllGenes(mockAllGenes)
setLoading(false)
}
fetchData()
}, [selectedYear, farmNo, selectedGenes])
if (loading) {
return (
<div className="h-[300px] flex items-center justify-center">
<p className="text-muted-foreground"> ...</p>
</div>
)
}
if (!farmNo) {
return (
<div className="h-[300px] flex items-center justify-center">
<div className="text-center">
<p className="text-muted-foreground mb-2"> </p>
<p className="text-sm text-muted-foreground"> </p>
</div>
</div>
)
}
// 필터에 따라 표시할 유전자 선택
const allDisplayGenes = hasFilter
? allGenes.filter(g => selectedGenes.includes(g.geneName))
: allGenes.slice(0, 6) // TOP 6 (보유율 높은 순으로 이미 정렬됨)
// 접기/펼치기 적용 (4개 기준)
// 단, 선택된 유전자가 있을 때는 모두 표시
const DISPLAY_LIMIT = 4
const displayGenes = hasFilter || isExpanded ? allDisplayGenes : allDisplayGenes.slice(0, DISPLAY_LIMIT)
const hasMore = !hasFilter && allDisplayGenes.length > DISPLAY_LIMIT
return (
<div className="space-y-4">
{/* 필터 배지 표시 */}
{hasFilter && (
<div className="flex items-center gap-2 flex-wrap">
<div className="flex items-center gap-1.5 text-xs text-gray-600">
<Filter className="h-3.5 w-3.5" />
<span className="font-medium"> :</span>
</div>
{selectedGenes.map(gene => (
<Badge
key={gene}
variant="secondary"
className="text-xs font-medium bg-blue-50 text-blue-700 border-blue-200"
>
{gene}
</Badge>
))}
</div>
)}
{/* 유전자별 바 차트 */}
<div className="space-y-2.5">
{displayGenes.map((gene, index) => (
<div key={gene.geneName} className="space-y-1">
{/* 유전자명 + 타입 배지 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-gray-800 min-w-[60px]">
{gene.geneName}
</span>
<Badge
variant="outline"
className={`text-xs px-2 py-0 ${
gene.geneType === '육량'
? 'bg-blue-50 text-blue-700 border-blue-200'
: 'bg-purple-50 text-purple-700 border-purple-200'
}`}
>
{gene.geneType}
</Badge>
</div>
<span className="text-sm font-bold text-gray-900">
{gene.farmRate}%
</span>
</div>
{/* 프로그레스 바 */}
<div className="relative h-7 bg-gray-100 rounded-full overflow-hidden">
{/* 우리 농장 */}
<div
className={`absolute h-full transition-all duration-800 ${
gene.geneType === '육량' ? 'bg-blue-500' : 'bg-purple-500'
}`}
style={{ width: `${gene.farmRate}%` }}
/>
{/* 지역 평균 표시 (점선) */}
<div
className="absolute h-full border-l-2 border-dashed border-gray-400"
style={{ left: `${gene.regionAvgRate}%` }}
title={`지역 평균: ${gene.regionAvgRate}%`}
/>
</div>
{/* 지역 평균 레이블 */}
<div className="flex justify-end">
<span className="text-xs text-gray-500">
: {gene.regionAvgRate}%
</span>
</div>
</div>
))}
</div>
{/* 더보기/접기 버튼 */}
{hasMore && (
<div className="flex justify-center">
<Button
variant="ghost"
size="sm"
onClick={() => setIsExpanded(!isExpanded)}
className="text-sm text-gray-600 hover:text-gray-900 hover:bg-gray-100"
>
{isExpanded ? (
<>
<ChevronUp className="h-4 w-4 mr-1" />
</>
) : (
<>
<ChevronDown className="h-4 w-4 mr-1" />
{allDisplayGenes.length - DISPLAY_LIMIT}
</>
)}
</Button>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,245 @@
'use client'
import { useState, useEffect } from "react"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Search, X, Filter, Sparkles } from "lucide-react"
import { geneApi, type MarkerModel } from "@/lib/api/gene.api"
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
interface GeneSearchDrawerProps {
open: boolean
onOpenChange: (open: boolean) => void
selectedGenes: string[]
onGenesChange: (genes: string[]) => void
}
export function GeneSearchModal({ open, onOpenChange, selectedGenes, onGenesChange }: GeneSearchDrawerProps) {
const [allGenes, setAllGenes] = useState<MarkerModel[]>([])
const [loading, setLoading] = useState(false)
const [searchQuery, setSearchQuery] = useState("")
const [filterType, setFilterType] = useState<'ALL' | 'QTY' | 'QLT'>('ALL')
// 모달 열릴 때 전체 유전자 로드
useEffect(() => {
if (open) {
loadAllGenes()
}
}, [open])
const loadAllGenes = async () => {
try {
setLoading(true)
const genes = await geneApi.getAllMarkers()
setAllGenes(genes)
} catch {
// 유전자 로드 실패 시 빈 배열 유지
} finally {
setLoading(false)
}
}
// 검색 및 필터링
const filteredGenes = allGenes.filter((gene) => {
// 타입 필터
if (filterType !== 'ALL' && gene.markerTypeCd !== filterType) {
return false
}
// 검색어 필터
if (searchQuery) {
const query = searchQuery.toLowerCase()
return (
gene.markerNm.toLowerCase().includes(query) ||
gene.markerDesc?.toLowerCase().includes(query) ||
gene.relatedTrait?.toLowerCase().includes(query)
)
}
return true
})
const toggleGene = (markerNm: string) => {
if (selectedGenes.includes(markerNm)) {
onGenesChange(selectedGenes.filter(g => g !== markerNm))
} else {
onGenesChange([...selectedGenes, markerNm])
}
}
const selectAllFiltered = () => {
const newGenes = [...selectedGenes]
filteredGenes.forEach(gene => {
if (!newGenes.includes(gene.markerNm)) {
newGenes.push(gene.markerNm)
}
})
onGenesChange(newGenes)
}
const clearAll = () => {
onGenesChange([])
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[95vw] w-full h-[95vh] flex flex-col p-0 gap-0">
{/* 헤더 */}
<DialogHeader className="px-5 pt-5 pb-3 border-b flex-shrink-0">
<div className="flex items-center gap-2.5">
<div className="p-1.5 bg-primary/10 rounded-lg">
<Sparkles className="h-4 w-4 text-primary" />
</div>
<div>
<DialogTitle className="text-lg font-bold"> </DialogTitle>
<DialogDescription className="text-xs text-muted-foreground mt-0.5">
<span className="font-semibold text-foreground">{allGenes.length.toLocaleString()}</span> / <span className="font-semibold text-primary">{selectedGenes.length}</span>
</DialogDescription>
</div>
</div>
</DialogHeader>
{/* 검색 및 필터 */}
<div className="px-4 py-3 space-y-3 flex-shrink-0 bg-muted/20">
{/* 검색바 */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="유전자명, 설명, 관련 형질로 검색..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 pr-9 h-10 text-sm bg-background"
autoFocus
/>
{searchQuery && (
<button
onClick={() => setSearchQuery("")}
className="absolute right-2.5 top-1/2 -translate-y-1/2 hover:bg-muted rounded-full p-1 transition-colors"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
{/* 필터 탭 및 액션 버튼 */}
<div className="flex items-center justify-between gap-3">
<Tabs value={filterType} onValueChange={(v) => setFilterType(v as any)} className="flex-1">
<TabsList className="w-full grid grid-cols-3 h-9">
<TabsTrigger value="ALL" className="text-xs">
<span className="ml-1 font-semibold">({allGenes.length})</span>
</TabsTrigger>
<TabsTrigger value="QTY" className="text-xs">
<div className="flex items-center gap-1">
<div className="w-1.5 h-1.5 rounded-full bg-[#2563eb]"></div>
<span className="ml-1 font-semibold">({allGenes.filter(g => g.markerTypeCd === 'QTY').length})</span>
</div>
</TabsTrigger>
<TabsTrigger value="QLT" className="text-xs">
<div className="flex items-center gap-1">
<div className="w-1.5 h-1.5 rounded-full bg-slate-400"></div>
<span className="ml-1 font-semibold">({allGenes.filter(g => g.markerTypeCd === 'QLT').length})</span>
</div>
</TabsTrigger>
</TabsList>
</Tabs>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={selectAllFiltered}
disabled={filteredGenes.length === 0}
className="h-8 text-xs px-3"
>
</Button>
<Button
variant="outline"
size="sm"
onClick={clearAll}
disabled={selectedGenes.length === 0}
className="h-8 text-xs px-3"
>
</Button>
</div>
</div>
</div>
{/* 유전자 목록 */}
<div className="flex-1 overflow-hidden">
{loading ? (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<div className="animate-spin rounded-full h-10 w-10 border-2 border-slate-200 border-t-[#2563eb] mx-auto mb-4"></div>
<p className="text-muted-foreground font-medium"> ...</p>
</div>
</div>
) : filteredGenes.length > 0 ? (
<ScrollArea className="h-full px-4">
<div className="flex flex-wrap gap-1.5 py-3">
{filteredGenes.map((gene) => {
const isSelected = selectedGenes.includes(gene.markerNm)
const isQuantity = gene.markerTypeCd === 'QTY'
return (
<Badge
key={gene.markerNm}
variant={isSelected ? "default" : "outline"}
className={`cursor-pointer transition-colors text-xs h-7 px-2.5 ${
isSelected
? isQuantity
? 'bg-[#2563eb] text-white hover:bg-[#2563eb]/90 border-[#2563eb]'
: 'bg-slate-400 text-white hover:bg-slate-500 border-slate-400'
: isQuantity
? 'border-[#2563eb]/40 text-[#2563eb] hover:bg-[#2563eb]/5 hover:border-[#2563eb]'
: 'border-slate-300 text-slate-600 hover:bg-slate-50 hover:border-slate-400'
}`}
onClick={() => toggleGene(gene.markerNm)}
title={`${gene.markerNm}\n${gene.markerDesc || ''}\n${gene.relatedTrait ? `관련 형질: ${gene.relatedTrait}` : ''}`}
>
{gene.markerNm}
</Badge>
)
})}
</div>
</ScrollArea>
) : (
<div className="flex items-center justify-center h-full">
<div className="text-center text-muted-foreground">
<Filter className="h-16 w-16 mx-auto mb-4 opacity-40" />
<p className="text-lg font-semibold"> </p>
<p className="text-sm mt-2"> </p>
</div>
</div>
)}
</div>
{/* 하단 버튼 */}
<div className="px-4 py-3 border-t flex justify-between items-center flex-shrink-0 bg-muted/20">
<div className="text-sm">
{searchQuery && (
<span className="text-muted-foreground mr-3">
: <span className="font-semibold text-foreground">{filteredGenes.length.toLocaleString()}</span>
</span>
)}
<span className="text-muted-foreground">
: <span className="font-bold text-primary text-base">{selectedGenes.length}</span>
</span>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)} className="h-9 px-4">
</Button>
<Button onClick={() => onOpenChange(false)} className="h-9 px-4">
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,181 @@
'use client'
import { PieChart as PieChartIcon } from "lucide-react"
import { useEffect, useState } from "react"
import { apiClient } from "@/lib/api"
import { ResponsiveContainer, PieChart, Pie, Cell, Tooltip } from 'recharts'
interface DistributionData {
name: string
value: number
color: string
range: string
description: string
}
interface GenomeDistributionDonutProps {
farmNo: number | null
}
export function GenomeDistributionDonut({ farmNo }: GenomeDistributionDonutProps) {
const [data, setData] = useState<DistributionData[]>([])
const [totalCount, setTotalCount] = useState(0)
const [loading, setLoading] = useState(true)
useEffect(() => {
const fetchData = async () => {
if (!farmNo) {
setLoading(false)
return
}
try {
const response = await apiClient.post('/cow/ranking', {
filterOptions: { farmNo },
rankingOptions: {
criteriaType: 'GENOME',
traitConditions: [
{ traitNm: '도체중', weight: 0.25 },
{ traitNm: '근내지방도', weight: 0.25 },
{ traitNm: '등심단면적', weight: 0.25 },
{ traitNm: '등지방두께', weight: 0.25 },
]
}
})
const result = response.data || response
const items = result.items || []
setTotalCount(items.length)
const distribution = {
top: 0, // 0σ 이상
middle: 0, // -1.0σ ~ 0σ
bottom: 0 // -1.0σ 이하
}
items.forEach((item: any) => {
const score = item.sortValue || 0
if (score >= 0) distribution.top++
else if (score >= -1.0) distribution.middle++
else distribution.bottom++
})
setData([
{ name: '우수', value: distribution.top, color: '#10b981', range: '0σ 이상', description: '평균보다 우수해요' },
{ name: '양호', value: distribution.middle, color: '#1482B0', range: '-1.0σ ~ 0σ', description: '평균 수준이에요' },
{ name: '개선필요', value: distribution.bottom, color: '#94a3b8', range: '-1.0σ 이하', description: '조금 더 신경써요' },
].filter(d => d.value > 0))
} catch (error) {
console.error('분포 데이터 로드 실패:', error)
} finally {
setLoading(false)
}
}
fetchData()
}, [farmNo])
if (loading) {
return (
<div className="bg-white rounded-xl border border-slate-100 p-4">
<div className="flex items-center justify-center h-[280px]">
<div className="w-6 h-6 border-2 border-slate-200 border-t-[#1482B0] rounded-full animate-spin"></div>
</div>
</div>
)
}
return (
<div className="bg-white rounded-xl border border-slate-100 overflow-hidden">
{/* 헤더 */}
<div className="px-4 py-3 border-b border-slate-100">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-violet-500 to-violet-600 flex items-center justify-center shadow-md shadow-violet-500/20">
<PieChartIcon className="w-4 h-4 text-white" />
</div>
<div>
<h3 className="text-sm font-semibold text-slate-900"> </h3>
<p className="text-[10px] text-slate-500"> {totalCount} </p>
</div>
</div>
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 rounded-lg text-[10px] font-semibold">{totalCount}</span>
</div>
</div>
{/* 차트 */}
<div className="p-5">
<div className="flex flex-col items-center">
<div className="relative w-[180px] h-[180px]">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={data}
cx="50%"
cy="50%"
innerRadius={54}
outerRadius={86}
paddingAngle={3}
dataKey="value"
>
{data.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} stroke="white" strokeWidth={2} />
))}
</Pie>
<Tooltip
content={({ active, payload }) => {
if (active && payload && payload.length) {
const item = payload[0].payload
return (
<div className="bg-slate-900 px-4 py-3 rounded-xl shadow-xl border border-slate-700">
<p className="text-white font-bold mb-2">{item.name}</p>
<p className="text-slate-200 text-sm">{item.description}</p>
<div className="mt-2 pt-2 border-t border-slate-700">
<p className="text-slate-300 text-sm">{item.value} ({Math.round(item.value / totalCount * 100)}%)</p>
<p className="text-slate-400 text-xs mt-1">{item.range}</p>
</div>
</div>
)
}
return null
}}
/>
</PieChart>
</ResponsiveContainer>
{/* 중앙 텍스트 */}
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-3xl font-bold text-slate-900">{totalCount}</span>
<span className="text-xs text-slate-500 mt-1"></span>
</div>
</div>
{/* 범례 */}
<div className="w-full mt-5 pt-4 border-t border-slate-100">
<div className="space-y-2.5">
{data.map((item, idx) => (
<div key={idx} className="flex items-center justify-between p-3 bg-slate-50 rounded-lg border border-slate-100 hover:bg-slate-100/50 transition-colors">
<div className="flex items-center gap-3">
<div className="w-4 h-4 rounded-full flex-shrink-0 shadow-sm" style={{ backgroundColor: item.color }}></div>
<div className="flex flex-col">
<span className="text-sm font-semibold text-slate-900">{item.name}</span>
<span className="text-[10px] text-slate-500">{item.description}</span>
</div>
</div>
<div className="text-right">
<p className="text-base font-bold text-slate-900">{item.value}<span className="text-xs text-slate-500 font-normal ml-0.5"></span></p>
<p className="text-[10px] text-slate-500">{Math.round(item.value / totalCount * 100)}%</p>
</div>
</div>
))}
</div>
<p className="text-[10px] text-slate-400 mt-4 text-center leading-relaxed">
σ() <br/>
0
</p>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,250 @@
'use client'
import { Target } from "lucide-react"
import { useEffect, useState } from "react"
import { apiClient } from "@/lib/api"
import { ResponsiveContainer, RadarChart, PolarGrid, PolarAngleAxis, PolarRadiusAxis, Radar, Tooltip } from 'recharts'
interface TraitScore {
trait: string
diff: number
myFarm: number
region: number
}
interface GenomeRadarChartProps {
farmNo: number | null
}
export function GenomeRadarChart({ farmNo }: GenomeRadarChartProps) {
const [data, setData] = useState<TraitScore[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
const fetchData = async () => {
if (!farmNo) {
setLoading(false)
return
}
try {
const traits = [
{ name: '도체중', key: '도체중' },
{ name: '근내지방', key: '근내지방도' },
{ name: '등심단면적', key: '등심단면적' },
{ name: '등지방', key: '등지방두께' },
{ name: '12개월체중', key: '12개월령체중' },
]
const results: TraitScore[] = []
for (const trait of traits) {
try {
const farmResponse = await apiClient.post('/cow/ranking', {
filterOptions: { farmNo },
rankingOptions: {
criteriaType: 'GENOME',
traitConditions: [{ traitNm: trait.key, weight: 1.0 }]
}
})
const globalResponse = await apiClient.post('/cow/ranking/global', {
rankingOptions: {
criteriaType: 'GENOME',
traitConditions: [{ traitNm: trait.key, weight: 1.0 }]
}
})
const farmResult = farmResponse.data || farmResponse
const globalResult = globalResponse.data || globalResponse
const farmScores = farmResult.items?.map((item: any) => {
const traitDetail = item.details?.find((d: any) => d.code === trait.key)
return traitDetail?.value ?? item.sortValue ?? 0
}) || []
const farmAvgScore = farmScores.length > 0
? farmScores.reduce((sum: number, s: number) => sum + s, 0) / farmScores.length
: 0
const globalScores = globalResult.items?.map((item: any) => {
const traitDetail = item.details?.find((d: any) => d.code === trait.key)
return traitDetail?.value ?? item.sortValue ?? 0
}) || []
const regionAvgScore = globalScores.length > 0
? globalScores.reduce((sum: number, s: number) => sum + s, 0) / globalScores.length
: 0
const diff = farmAvgScore - regionAvgScore
results.push({
trait: trait.name,
diff: parseFloat(diff.toFixed(2)),
myFarm: parseFloat(farmAvgScore.toFixed(2)),
region: parseFloat(regionAvgScore.toFixed(2))
})
} catch (error) {
console.error(`형질 ${trait.name} 로드 실패:`, error)
}
}
setData(results)
} catch (error) {
console.error('레이더 차트 데이터 로드 실패:', error)
} finally {
setLoading(false)
}
}
fetchData()
}, [farmNo])
if (loading) {
return (
<div className="bg-white rounded-xl border border-slate-100 p-4">
<div className="flex items-center justify-center h-[280px]">
<div className="w-6 h-6 border-2 border-slate-200 border-t-[#1482B0] rounded-full animate-spin"></div>
</div>
</div>
)
}
const validDiffs = data.filter(d => !isNaN(d.diff))
const avgDiff = validDiffs.length > 0
? validDiffs.reduce((sum, d) => sum + d.diff, 0) / validDiffs.length
: 0
return (
<div className="bg-white rounded-xl border border-slate-100 overflow-hidden">
{/* 헤더 */}
<div className="px-4 py-3 border-b border-slate-100">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-sky-500 to-sky-600 flex items-center justify-center shadow-md shadow-sky-500/20">
<Target className="w-4 h-4 text-white" />
</div>
<div>
<h3 className="text-sm font-semibold text-slate-900"> </h3>
<p className="text-[10px] text-slate-500"> </p>
</div>
</div>
<div className={`px-2.5 py-1 rounded-lg text-xs font-bold border ${avgDiff >= 0
? 'bg-emerald-50 text-emerald-700 border-emerald-200'
: 'bg-red-50 text-red-600 border-red-200'
}`}>
{avgDiff > 0 ? '+' : ''}{avgDiff.toFixed(2)}σ
</div>
</div>
</div>
{/* 차트 */}
<div className="p-5">
<div className="bg-gradient-to-br from-slate-50 to-blue-50/30 rounded-xl p-4">
<ResponsiveContainer width="100%" height={240}>
<RadarChart data={data} margin={{ top: 30, right: 40, bottom: 30, left: 40 }}>
<defs>
<linearGradient id="radarGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#1482B0" stopOpacity={0.4} />
<stop offset="100%" stopColor="#1482B0" stopOpacity={0.1} />
</linearGradient>
</defs>
<PolarGrid
stroke="#cbd5e1"
strokeWidth={1.5}
strokeOpacity={0.5}
/>
<PolarAngleAxis
dataKey="trait"
tick={{ fontSize: 13, fill: '#334155', fontWeight: 600 }}
tickLine={false}
/>
<PolarRadiusAxis
angle={90}
domain={[-1.5, 1.5]}
tick={{ fontSize: 10, fill: '#64748b' }}
tickCount={4}
axisLine={false}
/>
<Radar
name="보은군 평균"
dataKey={() => 0}
stroke="#94a3b8"
fill="none"
strokeWidth={2}
strokeDasharray="5 3"
strokeOpacity={0.6}
/>
<Radar
name="내농장"
dataKey="diff"
stroke="#1482B0"
fill="url(#radarGradient)"
strokeWidth={3}
dot={{
fill: '#fff',
strokeWidth: 3,
stroke: '#1482B0',
r: 6,
strokeOpacity: 1
}}
/>
<Tooltip
content={({ active, payload }) => {
if (active && payload && payload.length) {
const item = payload[0]?.payload
const diff = item?.diff ?? 0
const myFarm = item?.myFarm ?? 0
const region = item?.region ?? 0
return (
<div className="bg-slate-900 px-4 py-3 rounded-xl text-sm shadow-xl border border-slate-700">
<p className="text-white font-bold mb-2">{item?.trait}</p>
<div className="space-y-1 text-slate-300 text-xs">
<p>: <span className="text-[#1482B0] font-semibold">{myFarm > 0 ? '+' : ''}{myFarm}σ</span></p>
<p>: <span className="text-slate-400">{region > 0 ? '+' : ''}{region}σ</span></p>
</div>
<div className={`mt-2 pt-2 border-t border-slate-700 font-bold ${diff >= 0.3 ? 'text-emerald-400' :
diff <= -0.3 ? 'text-amber-400' :
'text-slate-300'
}`}>
{diff >= 0.3 ? '▲' : diff <= -0.3 ? '▼' : '='} {diff > 0 ? '+' : ''}{diff.toFixed(2)}σ
</div>
</div>
)
}
return null
}}
/>
</RadarChart>
</ResponsiveContainer>
</div>
{/* 범례 */}
<div className="flex items-center justify-center gap-6 mt-4 pb-3">
<div className="flex items-center gap-2">
<div className="w-5 h-0.5 bg-slate-400 opacity-60" style={{ borderTop: '2px dashed #94a3b8' }}></div>
<span className="text-xs text-slate-600"> </span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded-full bg-white border-3 border-[#1482B0] shadow-sm"></div>
<span className="text-xs text-slate-900 font-semibold"> </span>
</div>
</div>
{/* 형질별 수치 */}
<div className="grid grid-cols-5 gap-2 mt-4 pt-4 border-t border-slate-200">
{data.map((item, idx) => (
<div key={idx} className="text-center">
<p className="text-[10px] text-slate-500 mb-1 truncate font-medium">{item.trait}</p>
<p className={`text-sm font-bold ${item.diff >= 0.3 ? 'text-emerald-600' :
item.diff <= -0.3 ? 'text-amber-600' :
'text-slate-700'
}`}>
{item.diff > 0 ? '+' : ''}{item.diff.toFixed(1)}
</p>
</div>
))}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,208 @@
'use client'
import { ArrowUpRight, ArrowDownRight, TrendingUp, TrendingDown } from "lucide-react"
import { useEffect, useState } from "react"
import { apiClient } from "@/lib/api"
interface GenomeData {
trait: string
score: number
type: string
}
interface GenomeStrengthsWeaknessesProps {
farmNo?: number | null
}
export function GenomeStrengthsWeaknesses({ farmNo }: GenomeStrengthsWeaknessesProps) {
const [allMetrics, setAllMetrics] = useState<GenomeData[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
const fetchTraitScores = async () => {
if (!farmNo) {
setLoading(false)
return
}
try {
const traits = [
{ name: '도체중', key: '도체중' },
{ name: '근내지방도', key: '근내지방도' },
{ name: '등심단면적', key: '등심단면적' },
{ name: '등지방두께', key: '등지방두께' },
{ name: '12개월령체중', key: '12개월령체중' },
{ name: '체고', key: '체고' },
]
const traitScores: GenomeData[] = []
for (const trait of traits) {
try {
const rankingRequest = {
filterOptions: { farmNo: farmNo },
rankingOptions: {
criteriaType: 'GENOME',
traitConditions: [{ traitNm: trait.key, weight: 1.0 }]
}
}
const response = await apiClient.post('/cow/ranking', rankingRequest)
const rankingResult = response.data || response
const scores = rankingResult.items?.map((item: any) => item.sortValue) || []
const avgScore = scores.length > 0
? scores.reduce((sum: number, score: number) => sum + score, 0) / scores.length
: 0
traitScores.push({
trait: trait.name,
score: parseFloat(avgScore.toFixed(2)),
type: '유전체'
})
} catch (error) {
console.error(`[강점/약점] 형질 ${trait.name} 데이터 로드 실패:`, error)
}
}
setAllMetrics(traitScores)
} catch (error) {
console.error('형질 점수 로드 실패:', error)
setAllMetrics([])
} finally {
setLoading(false)
}
}
fetchTraitScores()
}, [farmNo])
const strengths = [...allMetrics].sort((a, b) => b.score - a.score).slice(0, 3)
const weaknesses = [...allMetrics].sort((a, b) => a.score - b.score).slice(0, 3)
if (loading) {
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
{[1, 2].map((i) => (
<div key={i} className="bg-white rounded-xl border border-slate-100 p-4">
<div className="flex items-center justify-center h-[140px]">
<div className="w-6 h-6 border-2 border-slate-200 border-t-[#1482B0] rounded-full animate-spin"></div>
</div>
</div>
))}
</div>
)
}
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* 강점 */}
<div
className="bg-white rounded-xl border border-slate-100 overflow-hidden cursor-pointer transition-all duration-300 hover:shadow-lg hover:-translate-y-1 group"
onClick={() => window.location.href = '/dashboard/strengths-weaknesses'}
>
<div className="px-5 py-4 border-b border-slate-100 bg-gradient-to-r from-emerald-50 to-transparent">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-emerald-500 to-emerald-600 flex items-center justify-center shadow-lg shadow-emerald-500/30">
<TrendingUp className="w-5 h-5 text-white" />
</div>
<div>
<h3 className="text-base font-bold text-slate-900"> </h3>
<p className="text-xs text-slate-500 mt-0.5"> </p>
</div>
</div>
<span className="px-3 py-1 bg-emerald-100 text-emerald-700 rounded-lg text-xs font-bold border border-emerald-200 shadow-sm">TOP 3</span>
</div>
</div>
<div className="p-5">
{strengths.length === 0 ? (
<div className="flex items-center justify-center py-8 text-xs text-slate-400">
.
</div>
) : (
<div className="space-y-3">
{strengths.map((item, idx) => (
<div
key={idx}
className="flex items-center justify-between p-4 bg-gradient-to-r from-emerald-50 to-transparent rounded-xl hover:from-emerald-100 hover:to-emerald-50 transition-all duration-200 border-2 border-emerald-100 group-hover:border-emerald-200 shadow-sm"
>
<div className="flex items-center gap-3">
<span className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold shadow-md ${
idx === 0 ? 'bg-gradient-to-br from-emerald-500 to-emerald-600 text-white shadow-emerald-500/30' :
idx === 1 ? 'bg-emerald-200 text-emerald-800 border-2 border-emerald-300' :
'bg-emerald-100 text-emerald-700 border-2 border-emerald-200'
}`}>
{idx + 1}
</span>
<span className="text-sm font-bold text-slate-900">{item.trait}</span>
</div>
<div className="flex items-center gap-1 bg-white px-3 py-1.5 rounded-lg border border-emerald-200 shadow-sm">
<span className="text-lg font-bold text-emerald-600">
{item.score > 0 ? '+' : ''}{item.score}
</span>
<span className="text-xs text-emerald-500 font-semibold">σ</span>
</div>
</div>
))}
</div>
)}
</div>
</div>
{/* 약점 */}
<div
className="bg-white rounded-xl border border-slate-100 overflow-hidden cursor-pointer transition-all duration-300 hover:shadow-lg hover:-translate-y-1 group"
onClick={() => window.location.href = '/dashboard/strengths-weaknesses'}
>
<div className="px-5 py-4 border-b border-slate-100 bg-gradient-to-r from-amber-50 to-transparent">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-amber-500 to-amber-600 flex items-center justify-center shadow-lg shadow-amber-500/30">
<TrendingDown className="w-5 h-5 text-white" />
</div>
<div>
<h3 className="text-base font-bold text-slate-900"> </h3>
<p className="text-xs text-slate-500 mt-0.5"> </p>
</div>
</div>
<span className="px-3 py-1 bg-amber-100 text-amber-700 rounded-lg text-xs font-bold border border-amber-200 shadow-sm">BOTTOM 3</span>
</div>
</div>
<div className="p-5">
{weaknesses.length === 0 ? (
<div className="flex items-center justify-center py-8 text-xs text-slate-400">
.
</div>
) : (
<div className="space-y-3">
{weaknesses.map((item, idx) => (
<div
key={idx}
className="flex items-center justify-between p-4 bg-gradient-to-r from-amber-50 to-transparent rounded-xl hover:from-amber-100 hover:to-amber-50 transition-all duration-200 border-2 border-amber-100 group-hover:border-amber-200 shadow-sm"
>
<div className="flex items-center gap-3">
<span className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold shadow-md ${
idx === 0 ? 'bg-gradient-to-br from-amber-500 to-amber-600 text-white shadow-amber-500/30' :
idx === 1 ? 'bg-amber-200 text-amber-800 border-2 border-amber-300' :
'bg-amber-100 text-amber-700 border-2 border-amber-200'
}`}>
{idx + 1}
</span>
<span className="text-sm font-bold text-slate-900">{item.trait}</span>
</div>
<div className="flex items-center gap-1 bg-white px-3 py-1.5 rounded-lg border border-amber-200 shadow-sm">
<span className="text-lg font-bold text-amber-600">
{item.score > 0 ? '+' : ''}{item.score}
</span>
<span className="text-xs text-amber-500 font-semibold">σ</span>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,199 @@
'use client'
import { BarChart3 } from "lucide-react"
import { useEffect, useState } from "react"
import { apiClient } from "@/lib/api"
interface TraitData {
trait: string
regional: number
myFarm: number
}
interface GenomeTraitsTableProps {
farmNo?: number | null
}
export function GenomeTraitsTable({ farmNo }: GenomeTraitsTableProps) {
const [traitData, setTraitData] = useState<TraitData[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
const fetchTraitData = async () => {
if (!farmNo) {
setLoading(false)
return
}
try {
const traits = [
{ name: '12개월령체중', key: '12개월령체중' },
{ name: '도체중', key: '도체중' },
{ name: '근내지방도', key: '근내지방도' },
{ name: '등심단면적', key: '등심단면적' },
{ name: '등지방두께', key: '등지방두께' },
]
const results: TraitData[] = []
for (const trait of traits) {
try {
const farmResponse = await apiClient.post('/cow/ranking', {
filterOptions: { farmNo: farmNo },
rankingOptions: {
criteriaType: 'GENOME',
traitConditions: [{ traitNm: trait.key, weight: 1.0 }]
}
})
const globalResponse = await apiClient.post('/cow/ranking/global', {
rankingOptions: {
criteriaType: 'GENOME',
traitConditions: [{ traitNm: trait.key, weight: 1.0 }]
}
})
const farmResult = farmResponse.data || farmResponse
const globalResult = globalResponse.data || globalResponse
const farmScores = farmResult.items?.map((item: any) => item.sortValue) || []
const farmAvg = farmScores.length > 0
? farmScores.reduce((sum: number, score: number) => sum + score, 0) / farmScores.length
: 0
const globalScores = globalResult.items?.map((item: any) => item.sortValue) || []
const regionalAvg = globalScores.length > 0
? globalScores.reduce((sum: number, score: number) => sum + score, 0) / globalScores.length
: 0
results.push({
trait: trait.name,
myFarm: parseFloat(farmAvg.toFixed(2)),
regional: parseFloat(regionalAvg.toFixed(2))
})
} catch (error) {
console.error(`[형질 테이블] ${trait.name} 데이터 로드 실패:`, error)
}
}
setTraitData(results)
} catch (error) {
console.error('[형질 테이블] 전체 데이터 로드 실패:', error)
setTraitData([])
} finally {
setLoading(false)
}
}
fetchTraitData()
}, [farmNo])
const getTraitShortName = (name: string) => {
const shortNames: Record<string, string> = {
'12개월령체중': '12개월령체중',
'등심단면적': '등심단면적',
'등지방두께': '등지방두께',
'근내지방도': '근내지방도',
'도체중': '도체중'
}
return shortNames[name] || name
}
if (loading) {
return (
<div className="bg-white rounded-xl border border-slate-100 p-4">
<div className="flex items-center justify-center h-[180px]">
<div className="w-6 h-6 border-2 border-slate-200 border-t-[#1482B0] rounded-full animate-spin"></div>
</div>
</div>
)
}
return (
<div className="bg-white rounded-xl border border-slate-100 overflow-hidden">
{/* 헤더 */}
<div className="px-4 py-3 border-b border-slate-100">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-[#1482B0] to-[#0d5f82] flex items-center justify-center shadow-md shadow-[#1482B0]/20">
<BarChart3 className="w-4 h-4 text-white" />
</div>
<div>
<h3 className="text-sm font-semibold text-slate-900"> </h3>
<p className="text-[10px] text-slate-500"> </p>
</div>
</div>
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 rounded-lg text-[10px] font-semibold">5 </span>
</div>
</div>
{/* 콘텐츠 */}
<div className="p-5">
{traitData.length === 0 ? (
<div className="flex items-center justify-center py-8 text-xs text-slate-400">
.
</div>
) : (
<div className="space-y-4">
{traitData.map((item, idx) => {
const diff = item.myFarm - item.regional
const isPositive = diff >= 0
// σ를 0~100 스케일로 변환 (-3σ~+3σ → 0~100)
const toPercent = (sigma: number) => Math.min(100, Math.max(0, ((sigma + 3) / 6) * 100))
return (
<div key={idx} className="space-y-1.5">
{/* 형질명 + 차이 */}
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-slate-800">
{getTraitShortName(item.trait)}
</span>
<span className={`text-xs font-bold px-2.5 py-1 rounded-lg ${diff >= 0.3 ? 'bg-emerald-50 text-emerald-700 border border-emerald-200 shadow-sm' :
diff <= -0.3 ? 'bg-amber-50 text-amber-700 border border-amber-200 shadow-sm' :
'bg-slate-50 text-slate-700 border border-slate-200'
}`}>
{diff > 0 ? '+' : ''}{diff.toFixed(1)}σ
</span>
</div>
{/* 비교 바 */}
<div className="relative h-6 bg-slate-100 rounded-lg overflow-hidden shadow-inner">
{/* 보은군 바 */}
<div
className="absolute top-0 left-0 h-full bg-slate-300/80 rounded-lg transition-all duration-500"
style={{ width: `${toPercent(item.regional)}%` }}
/>
{/* 내농장 바 */}
<div
className={`absolute top-0 left-0 h-full rounded-lg transition-all duration-500 shadow-sm ${isPositive
? 'bg-gradient-to-r from-[#1482B0] via-[#1482B0] to-[#0d5f82]'
: 'bg-gradient-to-r from-slate-400 to-slate-500'
}`}
style={{ width: `${toPercent(item.myFarm)}%` }}
/>
</div>
{/* 라벨 */}
<div className="flex items-center justify-between text-[11px]">
<div className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-full bg-[#1482B0] shadow-sm"></div>
<span className="text-slate-600 font-medium">
<span className="font-bold text-slate-900">{item.myFarm > 0 ? '+' : ''}{item.myFarm}σ</span>
</span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-full bg-slate-300 shadow-sm"></div>
<span className="text-slate-500 font-medium">
{item.regional > 0 ? '+' : ''}{item.regional}σ
</span>
</div>
</div>
</div>
)
})}
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,98 @@
'use client'
import React from "react"
import { useRouter } from "next/navigation"
import { SidebarTrigger } from "@/components/ui/sidebar"
import { Separator } from "@/components/ui/separator"
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { useAuthStore } from "@/store/auth-store"
import { IconLogout, IconUser } from "@tabler/icons-react"
interface BreadcrumbItemData {
label: string
href?: string
}
interface AdminHeaderProps {
breadcrumbs: BreadcrumbItemData[]
}
export function AdminHeader({ breadcrumbs }: AdminHeaderProps) {
const { user, logout } = useAuthStore()
const router = useRouter()
const handleLogout = async () => {
await logout()
router.push('/login')
}
return (
<header className="flex h-16 shrink-0 items-center justify-between gap-2 border-b px-4">
<div className="flex items-center gap-2">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 h-4" />
<Breadcrumb>
<BreadcrumbList>
{breadcrumbs.map((item, index) => (
<React.Fragment key={index}>
{index > 0 && <BreadcrumbSeparator className="hidden md:block" />}
<BreadcrumbItem className={index === 0 ? "hidden md:block" : ""}>
{item.href ? (
<BreadcrumbLink href={item.href}>{item.label}</BreadcrumbLink>
) : (
<BreadcrumbPage>{item.label}</BreadcrumbPage>
)}
</BreadcrumbItem>
</React.Fragment>
))}
</BreadcrumbList>
</Breadcrumb>
</div>
{/* 사용자 메뉴 */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-10 gap-2 px-3 rounded-lg hover:bg-slate-100">
<Avatar className="h-8 w-8">
<AvatarImage alt={user?.userName} />
<AvatarFallback className="text-sm font-bold bg-primary text-white">
{user?.userName?.charAt(0)?.toUpperCase() || 'A'}
</AvatarFallback>
</Avatar>
<span className="hidden md:inline text-sm font-medium text-slate-900">{user?.userName || '관리자'}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel className="font-semibold">{user?.userName || '관리자'}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem disabled>
<IconUser className="mr-2 h-4 w-4" />
<span></span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout} className="text-red-600 font-medium">
<IconLogout className="mr-2 h-4 w-4" />
<span></span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</header>
)
}

View File

@@ -0,0 +1,109 @@
"use client"
import {
IconChartBar,
IconDashboard,
IconFileUpload,
IconInnerShadowTop,
IconSettings,
IconUsers
} from "@tabler/icons-react"
import * as React from "react"
import { NavMain } from "@/components/layout/nav-main"
import { NavSecondary } from "@/components/layout/nav-secondary"
import { NavUser } from "@/components/layout/nav-user"
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar"
const adminData = {
navMain: [
{
title: "관리자 대시보드",
url: "/admin",
icon: IconDashboard,
},
{
title: "파일 업로드",
url: "/admin/upload",
icon: IconFileUpload,
items: [
{
title: "유전체 접수내역",
url: "/admin/upload/reception",
},
{
title: "분석 의뢰 목록",
url: "/admin/upload/request-list",
},
{
title: "친자확인 결과",
url: "/admin/upload/paternity",
},
{
title: "불일치 개체 목록",
url: "/admin/upload/mismatch",
},
{
title: "유전체 분석 결과",
url: "/admin/upload/genome-result",
},
],
},
{
title: "농장주별 집계",
url: "/admin/genome-mapping",
icon: IconChartBar,
},
{
title: "사용자 관리",
url: "/admin/users",
icon: IconUsers,
},
],
navSecondary: [
{
title: "설정",
url: "/admin/settings",
icon: IconSettings,
},
],
}
export function AdminSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
return (
<Sidebar collapsible="offcanvas" {...props}>
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size="lg" asChild>
<a href="/admin">
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
<IconInnerShadowTop className="size-4" />
</div>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold"> </span>
<span className="truncate text-xs">Admin Panel</span>
</div>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<NavMain items={adminData.navMain} />
<NavSecondary items={adminData.navSecondary} className="mt-auto" />
</SidebarContent>
<SidebarFooter>
<NavUser />
</SidebarFooter>
</Sidebar>
)
}

View File

@@ -0,0 +1,103 @@
"use client"
import {
IconChartBar,
IconDashboard,
IconFileUpload,
IconInnerShadowTop,
IconListDetails,
IconShieldCheck,
IconUsers,
} from "@tabler/icons-react"
import * as React from "react"
import { NavMain } from "@/components/layout/nav-main"
import {
Sidebar,
SidebarContent,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar"
import { useAuthStore } from "@/store/auth-store"
// 일반 사용자 메뉴
const userNavMain = [
{
title: "대시보드",
url: "/dashboard",
icon: IconDashboard,
},
{
title: "개체 조회",
url: "/cow",
icon: IconListDetails,
items: [
{
title: "개체 목록",
url: "/cow",
},
],
},
]
// 관리자 메뉴
const adminNavMain = [
{
title: "관리자 대시보드",
url: "/admin",
icon: IconShieldCheck,
},
{
title: "파일 업로드",
url: "/admin/upload",
icon: IconFileUpload,
},
{
title: "농장주별 집계",
url: "/admin/genome-mapping",
icon: IconChartBar,
},
{
title: "사용자 관리",
url: "/admin/users",
icon: IconUsers,
},
]
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const { user } = useAuthStore()
// 관리자 여부에 따라 다른 메뉴 표시
const isAdmin = user?.userRole === 'ADMIN'
const navMain = isAdmin ? adminNavMain : userNavMain
const homeUrl = isAdmin ? "/admin" : "/dashboard"
return (
<Sidebar collapsible="offcanvas" {...props}>
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size="lg" asChild>
<a href={homeUrl}>
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
<IconInnerShadowTop className="size-4" />
</div>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">
{isAdmin ? "관리자 페이지" : "한우 유전체 분석"}
</span>
</div>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<NavMain items={navMain} />
</SidebarContent>
</Sidebar>
)
}

View File

@@ -0,0 +1,51 @@
"use client"
import { type Icon } from "@tabler/icons-react"
import { usePathname } from "next/navigation"
import {
SidebarGroup,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar"
export function NavDocuments({
items,
}: {
items: {
name: string
url: string
icon: Icon
}[]
}) {
const pathname = usePathname()
return (
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
<SidebarGroupLabel></SidebarGroupLabel>
<SidebarMenu>
{items.map((item) => {
const Icon = item.icon
const isActive = pathname === item.url
return (
<SidebarMenuItem key={item.name}>
<SidebarMenuButton
asChild
tooltip={item.name}
isActive={isActive}
>
<a href={item.url}>
<Icon />
<span>{item.name}</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
)
})}
</SidebarMenu>
</SidebarGroup>
)
}

View File

@@ -0,0 +1,56 @@
"use client"
import { type Icon } from "@tabler/icons-react"
import { usePathname } from "next/navigation"
import {
SidebarGroup,
SidebarGroupContent,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar"
export function NavMain({
items,
}: {
items: {
title: string
url: string
icon?: Icon
items?: {
title: string
url: string
}[]
}[]
}) {
const pathname = usePathname()
return (
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
{items.map((item) => {
const Icon = item.icon
const isActive = pathname === item.url || pathname?.startsWith(item.url + '/')
return (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
tooltip={item.title}
asChild
isActive={isActive}
>
<a href={item.url}>
{Icon && <Icon />}
<span>{item.title}</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
)
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
)
}

View File

@@ -0,0 +1,54 @@
"use client"
import * as React from "react"
import { type Icon } from "@tabler/icons-react"
import { usePathname } from "next/navigation"
import {
SidebarGroup,
SidebarGroupContent,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar"
export function NavSecondary({
items,
...props
}: {
items: {
title: string
url: string
icon: Icon
}[]
} & React.ComponentPropsWithoutRef<typeof SidebarGroup>) {
const pathname = usePathname()
return (
<SidebarGroup {...props}>
<SidebarGroupContent>
<SidebarMenu>
{items.map((item) => {
const Icon = item.icon
const isActive = pathname === item.url
return (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
asChild
tooltip={item.title}
isActive={isActive}
>
<a href={item.url}>
<Icon />
<span>{item.title}</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
)
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
)
}

View File

@@ -0,0 +1,95 @@
"use client"
import {
IconDotsVertical,
IconLogout,
} from "@tabler/icons-react"
import { useRouter } from "next/navigation"
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@/components/ui/avatar"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar"
import { useAuthStore } from "@/store/auth-store"
export function NavUser() {
const { isMobile } = useSidebar()
const router = useRouter()
const { user, logout } = useAuthStore()
// 로그인하지 않은 경우
if (!user) {
return null
}
// 사용자 이름 이니셜 생성
const getInitials = (name?: string) => {
if (!name) return 'U'
return name
.split(' ')
.map(part => part[0])
.join('')
.toUpperCase()
.slice(0, 2) || 'U'
}
const handleLogout = async () => {
try {
await logout()
router.push('/login')
} catch (error) {
console.error('로그아웃 실패:', error)
}
}
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<Avatar className="h-8 w-8 rounded-lg grayscale">
<AvatarImage src={undefined} alt={user.userName} />
<AvatarFallback className="rounded-lg">{getInitials(user.userName)}</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{user.userName}</span>
<span className="text-muted-foreground truncate text-xs">
{user.userEmail}
</span>
</div>
<IconDotsVertical className="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
side={isMobile ? "bottom" : "right"}
align="end"
sideOffset={4}
>
<DropdownMenuItem onClick={handleLogout}>
<IconLogout />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
)
}

View File

@@ -0,0 +1,156 @@
'use client';
import { GlobalFilterDialog } from "@/components/common/global-filter-dialog";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { SidebarTrigger } from "@/components/ui/sidebar";
import { useAnalysisYear } from "@/contexts/AnalysisYearContext";
import { useGlobalFilter } from "@/contexts/GlobalFilterContext";
import { useAuthStore } from "@/store/auth-store";
import { Filter, LogOut, User } from "lucide-react";
import { useRouter } from "next/navigation";
export function SiteHeader() {
const { user, logout } = useAuthStore();
const router = useRouter();
const { filters } = useGlobalFilter();
const { selectedYear, setSelectedYear, availableYears } = useAnalysisYear();
const handleLogout = async () => {
await logout();
router.push('/login');
};
// 필터 활성화 여부 확인
const hasActiveFilters = filters.isActive && (
filters.selectedGenes.length > 0 ||
(filters.traitWeights && Object.values(filters.traitWeights).some(w => w > 0)) ||
(filters.inbreedingThreshold !== undefined && filters.inbreedingThreshold > 0)
);
// 필터 요약 텍스트 생성
const getFilterSummary = () => {
const parts: string[] = [];
// 유전자 - 개수만 표시
if (filters.selectedGenes.length > 0) {
parts.push(`유전자 ${filters.selectedGenes.length}`);
}
// 유전체 - 개수만 표시
if (filters.traitWeights) {
const activeWeightCount = Object.values(filters.traitWeights)
.filter(weight => weight > 0).length;
if (activeWeightCount > 0) {
parts.push(`유전체 ${activeWeightCount}`);
}
}
// 근친도 표시 (항상)
if (filters.inbreedingThreshold !== undefined && filters.inbreedingThreshold > 0) {
parts.push(`근친도 ${filters.inbreedingThreshold}%`);
}
return parts.join(' · ');
};
return (
<>
<header className="sticky top-0 z-50 w-full bg-white/80 backdrop-blur-sm border-b border-slate-200">
<div className="flex h-14 md:h-16 items-center justify-between gap-2 md:gap-3 px-3 md:px-4 lg:px-6">
{/* 왼쪽 영역 */}
<div className="flex items-center gap-2 md:gap-3 min-w-0 overflow-hidden">
<SidebarTrigger className="h-9 w-9 md:h-10 md:w-10 flex-shrink-0" />
{/* 활성화된 필터 표시 - 반응형 개선 */}
{hasActiveFilters && (
<div className="flex items-center gap-1 md:gap-2 px-1.5 md:px-3 py-1 md:py-2 bg-primary/10 rounded-md md:rounded-lg min-w-0">
<Filter className="h-4 w-4 md:h-4 md:w-4 text-primary flex-shrink-0" />
{/* 모바일: 숫자만, 데스크톱: 전체 텍스트 */}
<span className="text-[10px] md:text-sm text-primary font-medium truncate md:hidden">
{(() => {
const count = filters.selectedGenes.length +
(filters.traitWeights ? Object.values(filters.traitWeights).filter(w => w > 0).length : 0);
return count;
})()}
</span>
<span className="hidden md:inline text-sm text-primary font-medium truncate max-w-[200px] lg:max-w-[350px]">
{getFilterSummary()}
</span>
</div>
)}
</div>
{/* 오른쪽 영역 */}
<div className="flex items-center gap-2 md:gap-3 flex-shrink-0">
{/* 연도 선택 - 반응형 개선 */}
<Select
value={selectedYear.toString()}
onValueChange={(value) => setSelectedYear(Number(value))}
>
<SelectTrigger className="w-[80px] md:w-[120px] h-8 md:h-9 text-xs md:text-base font-semibold bg-slate-50 hover:bg-slate-100 border-slate-200 px-2 md:px-3">
<SelectValue>
<span className="font-medium">{selectedYear}</span>
</SelectValue>
</SelectTrigger>
<SelectContent>
{availableYears.map((year) => (
<SelectItem key={year} value={year.toString()} className="text-sm md:text-base">
{year}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 필터 버튼 */}
<GlobalFilterDialog />
{/* 사용자 메뉴 */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-9 w-9 md:h-10 md:w-auto md:gap-2 md:px-3 rounded-full md:rounded-lg p-0 md:p-2 hover:bg-slate-100">
<Avatar className="h-8 w-8 md:h-9 md:w-9">
<AvatarImage alt={user?.userName} />
<AvatarFallback className="text-xs md:text-sm font-bold">
{user?.userName?.charAt(0)?.toUpperCase() || 'U'}
</AvatarFallback>
</Avatar>
<span className="hidden md:inline text-sm md:text-base font-semibold text-slate-900">{user?.userName || '사용자'}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52 md:w-56">
<DropdownMenuLabel className="text-sm md:text-base font-semibold">{user?.userName || '사용자'}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem disabled className="text-sm md:text-base">
<User className="mr-2 h-4 w-4 md:h-5 md:w-5" />
<span></span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout} className="text-sm md:text-base text-red-600 font-medium">
<LogOut className="mr-2 h-4 w-4 md:h-5 md:w-5" />
<span></span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</header>
</>
)
}

View File

@@ -0,0 +1,66 @@
"use client"
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDownIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("border-b last:border-b-0", className)}
{...props}
/>
)
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
)
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
{...props}
>
<div className={cn("pt-0 pb-4", className)}>{children}</div>
</AccordionPrimitive.Content>
)
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View File

@@ -0,0 +1,53 @@
"use client"
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
)
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"bg-primary text-primary-foreground flex size-full items-center justify-center rounded-full",
className
)}
{...props}
/>
)
}
export { Avatar, AvatarImage, AvatarFallback }

View File

@@ -0,0 +1,46 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,109 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
className
)}
{...props}
/>
)
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
)
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "a"
return (
<Comp
data-slot="breadcrumb-link"
className={cn("hover:text-foreground transition-colors", className)}
{...props}
/>
)
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("text-foreground font-normal", className)}
{...props}
/>
)
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
)
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="size-4" />
<span className="sr-only">More</span>
</span>
)
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}

View File

@@ -0,0 +1,60 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 md:h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-9 md:h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-11 md:h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-10 md:size-9",
"icon-sm": "size-9 md:size-8",
"icon-lg": "size-11 md:size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@@ -0,0 +1,216 @@
"use client"
import * as React from "react"
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react"
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
}) {
const defaultClassNames = getDefaultClassNames()
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString("default", { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn(
"flex gap-4 flex-col md:flex-row relative",
defaultClassNames.months
),
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
nav: cn(
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_next
),
month_caption: cn(
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
defaultClassNames.month_caption
),
dropdowns: cn(
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
defaultClassNames.dropdowns
),
dropdown_root: cn(
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
defaultClassNames.dropdown_root
),
dropdown: cn(
"absolute bg-popover inset-0 opacity-0",
defaultClassNames.dropdown
),
caption_label: cn(
"select-none font-medium",
captionLayout === "label"
? "text-sm"
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
defaultClassNames.caption_label
),
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
defaultClassNames.weekday
),
week: cn("flex w-full mt-2", defaultClassNames.week),
week_number_header: cn(
"select-none w-(--cell-size)",
defaultClassNames.week_number_header
),
week_number: cn(
"text-[0.8rem] select-none text-muted-foreground",
defaultClassNames.week_number
),
day: cn(
"relative w-full h-full p-0 text-center [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
props.showWeekNumber
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-md"
: "[&:first-child[data-selected=true]_button]:rounded-l-md",
defaultClassNames.day
),
range_start: cn(
"rounded-l-md bg-accent",
defaultClassNames.range_start
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
today: cn(
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
defaultClassNames.today
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
)
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
)
}
if (orientation === "right") {
return (
<ChevronRightIcon
className={cn("size-4", className)}
{...props}
/>
)
}
return (
<ChevronDownIcon className={cn("size-4", className)} {...props} />
)
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">
{children}
</div>
</td>
)
},
...components,
}}
{...props}
/>
)
}
function CalendarDayButton({
className,
day,
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames()
const ref = React.useRef<HTMLButtonElement>(null)
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
return (
<Button
ref={ref}
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString()}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}
{...props}
/>
)
}
export { Calendar, CalendarDayButton }

View File

@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 p-4 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-4",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none tracking-tight font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("p-4 pt-0", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center p-4 pt-0", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -0,0 +1,421 @@
"use client"
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import { TrendingUp } from "lucide-react"
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts"
import { cn } from "@/lib/utils"
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card"
export const description = "A bar chart"
const chartData = [
{ month: "January", desktop: 186 },
{ month: "February", desktop: 305 },
{ month: "March", desktop: 237 },
{ month: "April", desktop: 73 },
{ month: "May", desktop: 209 },
{ month: "June", desktop: 214 },
]
const chartConfig = {
desktop: {
label: "Desktop",
color: "var(--chart-1)",
},
} satisfies ChartConfig
export function ChartBarDefault() {
return (
<Card>
<CardHeader>
<CardTitle>Bar Chart</CardTitle>
<CardDescription>January - June 2024</CardDescription>
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig}>
<BarChart accessibilityLayer data={chartData}>
<CartesianGrid vertical={false} />
<XAxis
dataKey="month"
tickLine={false}
tickMargin={10}
axisLine={false}
tickFormatter={(value) => value.slice(0, 3)}
/>
<ChartTooltip
cursor={false}
content={<ChartTooltipContent hideLabel />}
/>
<Bar dataKey="desktop" fill="var(--color-desktop)" radius={8} />
</BarChart>
</ChartContainer>
</CardContent>
<CardFooter className="flex-col items-start gap-2 text-sm">
<div className="flex gap-2 leading-none font-medium">
Trending up by 5.2% this month <TrendingUp className="h-4 w-4" />
</div>
<div className="text-muted-foreground leading-none">
Showing total visitors for the last 6 months
</div>
</CardFooter>
</Card>
)
}
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
}
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
return context
}
function ChartContainer({
id,
className,
children,
config,
...props
}: React.ComponentProps<"div"> & {
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
}) {
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color
)
if (!colorConfig.length) {
return null
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
)
}
const ChartTooltip = RechartsPrimitive.Tooltip
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
}) {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey || item?.dataKey || item?.name || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
}
if (!value) {
return null
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
<div
className={cn(
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color || item.payload.fill || item.color
return (
<div
key={item.dataKey}
className={cn(
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="text-foreground font-mono font-medium tabular-nums">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
const ChartLegend = RechartsPrimitive.Legend
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean
nameKey?: string
}) {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload
.filter((item) => item.type !== "none")
.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={item.value}
className={cn(
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
}
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey: string = key
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}

View File

@@ -0,0 +1,32 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="flex items-center justify-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

View File

@@ -0,0 +1,33 @@
"use client"
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
)
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

View File

@@ -0,0 +1,184 @@
"use client"
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { SearchIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
className
)}
{...props}
/>
)
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = true,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
description?: string
className?: string
showCloseButton?: boolean
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn("overflow-hidden p-0", className)}
showCloseButton={showCloseButton}
>
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3"
>
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className
)}
{...props}
/>
)
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
className
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("bg-border -mx-1 h-px", className)}
{...props}
/>
)
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

View File

@@ -0,0 +1,143 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@@ -0,0 +1,135 @@
"use client"
import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"
import { cn } from "@/lib/utils"
function Drawer({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
}
function DrawerTrigger({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
}
function DrawerPortal({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
}
function DrawerClose({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
}
function DrawerOverlay({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
return (
<DrawerPrimitive.Overlay
data-slot="drawer-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DrawerContent({
className,
children,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
return (
<DrawerPortal data-slot="drawer-portal">
<DrawerOverlay />
<DrawerPrimitive.Content
data-slot="drawer-content"
className={cn(
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
className
)}
{...props}
>
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
)
}
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-header"
className={cn(
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
className
)}
{...props}
/>
)
}
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function DrawerTitle({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function DrawerDescription({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}

View File

@@ -0,0 +1,257 @@
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

View File

@@ -0,0 +1,244 @@
"use client"
import { useMemo } from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
return (
<fieldset
data-slot="field-set"
className={cn(
"flex flex-col gap-6",
"has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
className
)}
{...props}
/>
)
}
function FieldLegend({
className,
variant = "legend",
...props
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
return (
<legend
data-slot="field-legend"
data-variant={variant}
className={cn(
"mb-3 font-medium",
"data-[variant=legend]:text-base",
"data-[variant=label]:text-sm",
className
)}
{...props}
/>
)
}
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-group"
className={cn(
"group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4",
className
)}
{...props}
/>
)
}
const fieldVariants = cva(
"group/field flex w-full gap-3 data-[invalid=true]:text-destructive",
{
variants: {
orientation: {
vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"],
horizontal: [
"flex-row items-center",
"[&>[data-slot=field-label]]:flex-auto",
"has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
],
responsive: [
"flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto",
"@md/field-group:[&>[data-slot=field-label]]:flex-auto",
"@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
],
},
},
defaultVariants: {
orientation: "vertical",
},
}
)
function Field({
className,
orientation = "vertical",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
return (
<div
role="group"
data-slot="field"
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
)
}
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-content"
className={cn(
"group/field-content flex flex-1 flex-col gap-1.5 leading-snug",
className
)}
{...props}
/>
)
}
function FieldLabel({
className,
...props
}: React.ComponentProps<typeof Label>) {
return (
<Label
data-slot="field-label"
className={cn(
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50",
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4",
"has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10",
className
)}
{...props}
/>
)
}
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-label"
className={cn(
"flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50",
className
)}
{...props}
/>
)
}
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="field-description"
className={cn(
"text-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance",
"last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5",
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
className
)}
{...props}
/>
)
}
function FieldSeparator({
children,
className,
...props
}: React.ComponentProps<"div"> & {
children?: React.ReactNode
}) {
return (
<div
data-slot="field-separator"
data-content={!!children}
className={cn(
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
className
)}
{...props}
>
<Separator className="absolute inset-0 top-1/2" />
{children && (
<span
className="bg-background text-muted-foreground relative mx-auto block w-fit px-2"
data-slot="field-separator-content"
>
{children}
</span>
)}
</div>
)
}
function FieldError({
className,
children,
errors,
...props
}: React.ComponentProps<"div"> & {
errors?: Array<{ message?: string } | undefined>
}) {
const content = useMemo(() => {
if (children) {
return children
}
if (!errors?.length) {
return null
}
if (errors?.length == 1) {
return errors[0]?.message
}
return (
<ul className="ml-4 flex list-disc flex-col gap-1">
{errors.map(
(error, index) =>
error?.message && <li key={index}>{error.message}</li>
)}
</ul>
)
}, [children, errors])
if (!content) {
return null
}
return (
<div
role="alert"
data-slot="field-error"
className={cn("text-destructive text-sm font-normal", className)}
{...props}
>
{content}
</div>
)
}
export {
Field,
FieldLabel,
FieldDescription,
FieldError,
FieldGroup,
FieldLegend,
FieldSeparator,
FieldSet,
FieldContent,
FieldTitle,
}

View File

@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-10 w-full min-w-0 rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }

View File

@@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@@ -0,0 +1,48 @@
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

View File

@@ -0,0 +1,36 @@
"use client"
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils"
interface ProgressProps extends React.ComponentProps<typeof ProgressPrimitive.Root> {
indicatorClassName?: string
}
function Progress({
className,
value,
indicatorClassName,
...props
}: ProgressProps) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className={cn("bg-primary h-full w-full flex-1 transition-all", indicatorClassName)}
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
)
}
export { Progress }

View File

@@ -0,0 +1,45 @@
"use client"
import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function RadioGroup({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn("grid gap-3", className)}
{...props}
/>
)
}
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="relative flex items-center justify-center"
>
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
}
export { RadioGroup, RadioGroupItem }

View File

@@ -0,0 +1,58 @@
"use client"
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }

View File

@@ -0,0 +1,187 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "popper",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

@@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }

View File

@@ -0,0 +1,139 @@
"use client"
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className
)}
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-md opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none p-1.5">
<XIcon className="size-6" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@@ -0,0 +1,730 @@
"use client"
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, VariantProps } from "class-variance-authority"
import { Menu, PanelLeftIcon } from "lucide-react"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "15em" // 288px (데스크톱 - 30% 증가)
const SIDEBAR_WIDTH_MOBILE = "16rem" // 256px (모바일 - 적당히 증가)
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
type SidebarContextProps = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}) {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open]
)
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
className
)}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
)
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn(
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
className
)}
{...props}
>
{children}
</div>
)
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="!bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden shadow-[0_2px_8px_rgba(0,0,0,0.04)]"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
}
return (
<div
className="group peer text-sidebar-foreground hidden md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
)}
/>
<div
data-slot="sidebar-container"
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm shadow-[0_2px_8px_rgba(0,0,0,0.04)] border-r border-sidebar-border"
>
{children}
</div>
</div>
</div>
)
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar, isMobile } = useSidebar()
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
className={cn("size-7 md:size-7", isMobile && "size-9", className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
{isMobile ? (
<Menu className="h-6 w-6" />
) : (
<PanelLeftIcon />
)}
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar()
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props}
/>
)
}
/* 컴포넌트 변경 [variant=inset]:rounded-lg 둥근 모서리*/
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"bg-background relative flex w-full flex-1 flex-col",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-lg md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className
)}
{...props}
/>
)
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("bg-background h-8 w-full shadow-none", className)}
{...props}
/>
)
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("bg-sidebar-border mx-2 w-auto", className)}
{...props}
/>
)
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
/>
)
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
)
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div"
return (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className
)}
{...props}
/>
)
}
function SidebarGroupAction({
className,
asChild = false,
...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
)
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
)
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
)
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-5 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-10 text-sm md:h-11 md:text-base", // 반응형: 모바일 40px/14px, 데스크톱 44px/16px
sm: "h-8 text-xs md:h-9 md:text-sm", // 반응형 적용
lg: "h-12 text-base md:h-14 md:text-lg group-data-[collapsible=icon]:p-0!", // 반응형: 더 크게
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot : "button"
const { isMobile, state } = useSidebar()
const button = (
<Comp
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
)
if (!tooltip) {
return button
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
)
}
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
showOnHover?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
className
)}
{...props}
/>
)
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean
}) {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
}, [])
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
)
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
)
}
function SidebarMenuSubButton({
asChild = false,
size = "md",
isActive = false,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
size?: "sm" | "md"
isActive?: boolean
}) {
const Comp = asChild ? Slot : "a"
return (
<Comp
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
}

View File

@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)}
{...props}
/>
)
}
export { Skeleton }

View File

@@ -0,0 +1,63 @@
"use client"
import * as React from "react"
import * as SliderPrimitive from "@radix-ui/react-slider"
import { cn } from "@/lib/utils"
function Slider({
className,
defaultValue,
value,
min = 0,
max = 100,
...props
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
const _values = React.useMemo(
() =>
Array.isArray(value)
? value
: Array.isArray(defaultValue)
? defaultValue
: [min, max],
[value, defaultValue, min, max]
)
return (
<SliderPrimitive.Root
data-slot="slider"
defaultValue={defaultValue}
value={value}
min={min}
max={max}
className={cn(
"relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
className
)}
{...props}
>
<SliderPrimitive.Track
data-slot="slider-track"
className={cn(
"bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-3 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-3"
)}
>
<SliderPrimitive.Range
data-slot="slider-range"
className={cn(
"bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full"
)}
/>
</SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => (
<SliderPrimitive.Thumb
data-slot="slider-thumb"
key={index}
className="border-primary ring-ring/50 block size-5 shrink-0 rounded-full border-2 bg-white shadow-md transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
/>
))}
</SliderPrimitive.Root>
)
}
export { Slider }

View File

@@ -0,0 +1,25 @@
"use client"
import { useTheme } from "next-themes"
import { Toaster as Sonner, ToasterProps } from "sonner"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
} as React.CSSProperties
}
{...props}
/>
)
}
export { Toaster }

View File

@@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as SwitchPrimitive from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
function Switch({
className,
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitive.Root>
)
}
export { Switch }

View File

@@ -0,0 +1,116 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("text-muted-foreground mt-4 text-sm", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@@ -0,0 +1,66 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className
)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
)
}
export { Textarea }

View File

@@ -0,0 +1,127 @@
import * as React from "react"
import * as ToastPrimitives from "@radix-ui/react-toast"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const ToastProvider = ToastPrimitives.Provider
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className
)}
{...props}
/>
))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
)
})
Toast.displayName = ToastPrimitives.Root.displayName
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className
)}
{...props}
/>
))
ToastAction.displayName = ToastPrimitives.Action.displayName
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
))
ToastClose.displayName = ToastPrimitives.Close.displayName
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold", className)}
{...props}
/>
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
))
ToastDescription.displayName = ToastPrimitives.Description.displayName
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
type ToastActionElement = React.ReactElement<typeof ToastAction>
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
}

View File

@@ -0,0 +1,35 @@
"use client"
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from "@/components/ui/toast"
import { useToast } from "@/hooks/use-toast"
export function Toaster() {
const { toasts } = useToast()
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
)
})}
<ToastViewport />
</ToastProvider>
)
}

View File

@@ -0,0 +1,73 @@
"use client"
import * as React from "react"
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
import { type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { toggleVariants } from "@/components/ui/toggle"
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants>
>({
size: "default",
variant: "default",
})
function ToggleGroup({
className,
variant,
size,
children,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<ToggleGroupPrimitive.Root
data-slot="toggle-group"
data-variant={variant}
data-size={size}
className={cn(
"group/toggle-group flex w-fit items-center rounded-md data-[variant=outline]:shadow-xs",
className
)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
)
}
function ToggleGroupItem({
className,
children,
variant,
size,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext)
return (
<ToggleGroupPrimitive.Item
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
"min-w-0 flex-1 shrink-0 rounded-none shadow-none first:rounded-l-md last:rounded-r-md focus:z-10 focus-visible:z-10 data-[variant=outline]:border-l-0 data-[variant=outline]:first:border-l",
className
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
)
}
export { ToggleGroup, ToggleGroupItem }

View File

@@ -0,0 +1,47 @@
"use client"
import * as React from "react"
import * as TogglePrimitive from "@radix-ui/react-toggle"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-9 px-2 min-w-9",
sm: "h-8 px-1.5 min-w-8",
lg: "h-10 px-2.5 min-w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Toggle({
className,
variant,
size,
...props
}: React.ComponentProps<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive.Root
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Toggle, toggleVariants }

View File

@@ -0,0 +1,61 @@
"use client"
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
)
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }