페이지 화면 수정 및 dockerfile 수정

This commit is contained in:
2025-12-10 12:02:40 +09:00
parent 83dc4c86da
commit 6731eec802
11 changed files with 931 additions and 115 deletions

0
backend/src/gene/dto Normal file
View File

View File

@@ -0,0 +1,113 @@
import { BaseModel } from 'src/common/entities/base.entity';
import { GenomeRequestModel } from 'src/genome/entities/genome-request.entity';
import {
Column,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
/**
* 유전자 상세 정보 (tb_gene_detail)
* 1개체당 N개 SNP 마커 결과 저장
* 예: KOR002108023350, 1:110900379, 1, 110900379, [A/T], A, A, 설명
*/
@Entity({ name: 'tb_gene_detail' })
@Index('idx_gene_detail_cow_id', ['cowId'])
@Index('idx_gene_detail_snp_name', ['snpName'])
@Index('idx_gene_detail_cow_snp', ['cowId', 'snpName'])
export class GeneDetailModel extends BaseModel {
@PrimaryGeneratedColumn({
name: 'pk_gene_detail_no',
type: 'int',
comment: '유전자상세번호 PK',
})
pkGeneDetailNo: number;
@Column({
name: 'fk_request_no',
type: 'int',
nullable: true,
comment: '의뢰번호 FK',
})
fkRequestNo: number;
@Column({
name: 'cow_id',
type: 'varchar',
length: 20,
nullable: true,
comment: '개체식별번호 (KOR)',
})
cowId: string;
@Column({
name: 'snp_name',
type: 'varchar',
length: 100,
nullable: true,
comment: 'SNP 이름 (예: 1:110900379)',
})
snpName: string;
@Column({
name: 'chromosome',
type: 'varchar',
length: 10,
nullable: true,
comment: '염색체 위치 (Chr)',
})
chromosome: string;
@Column({
name: 'position',
type: 'varchar',
length: 20,
nullable: true,
comment: '위치 (Position)',
})
position: string;
@Column({
name: 'snp_type',
type: 'varchar',
length: 20,
nullable: true,
comment: 'SNP 구분 (예: [A/T])',
})
snpType: string;
@Column({
name: 'allele1',
type: 'varchar',
length: 10,
nullable: true,
comment: '첫번째 대립유전자 (Allele1...Top)',
})
allele1: string;
@Column({
name: 'allele2',
type: 'varchar',
length: 10,
nullable: true,
comment: '두번째 대립유전자 (Allele2...Top)',
})
allele2: string;
@Column({
name: 'remarks',
type: 'varchar',
length: 500,
nullable: true,
comment: '비고 (설명)',
})
remarks: string;
// Relations
@ManyToOne(() => GenomeRequestModel, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'fk_request_no' })
genomeRequest: GenomeRequestModel;
}

View File

@@ -1,7 +1,66 @@
import { Controller } from '@nestjs/common';
import { Controller, Get, Param, Post, Body } from '@nestjs/common';
import { GeneService } from './gene.service';
import { GeneDetailModel } from './entities/gene-detail.entity';
@Controller('gene')
export class GeneController {
constructor(private readonly geneService: GeneService) {}
/**
* 개체식별번호로 유전자 상세 정보 조회
* GET /gene/:cowId
*/
@Get(':cowId')
async findByCowId(@Param('cowId') cowId: string): Promise<GeneDetailModel[]> {
return this.geneService.findByCowId(cowId);
}
/**
* 개체별 유전자 요약 정보 조회
* GET /gene/summary/:cowId
*/
@Get('summary/:cowId')
async getGeneSummary(@Param('cowId') cowId: string): Promise<{
total: number;
homozygousCount: number;
heterozygousCount: number;
}> {
return this.geneService.getGeneSummary(cowId);
}
/**
* 의뢰번호로 유전자 상세 정보 조회
* GET /gene/request/:requestNo
*/
@Get('request/:requestNo')
async findByRequestNo(@Param('requestNo') requestNo: number): Promise<GeneDetailModel[]> {
return this.geneService.findByRequestNo(requestNo);
}
/**
* 유전자 상세 정보 단건 조회
* GET /gene/detail/:geneDetailNo
*/
@Get('detail/:geneDetailNo')
async findOne(@Param('geneDetailNo') geneDetailNo: number): Promise<GeneDetailModel> {
return this.geneService.findOne(geneDetailNo);
}
/**
* 유전자 상세 정보 생성
* POST /gene
*/
@Post()
async create(@Body() data: Partial<GeneDetailModel>): Promise<GeneDetailModel> {
return this.geneService.create(data);
}
/**
* 유전자 상세 정보 일괄 생성
* POST /gene/bulk
*/
@Post('bulk')
async createBulk(@Body() dataList: Partial<GeneDetailModel>[]): Promise<GeneDetailModel[]> {
return this.geneService.createBulk(dataList);
}
}

View File

@@ -1,9 +1,16 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { GeneService } from './gene.service';
import { GeneController } from './gene.controller';
import { GeneDetailModel } from './entities/gene-detail.entity';
import { GenomeRequestModel } from '../genome/entities/genome-request.entity';
@Module({
imports: [
TypeOrmModule.forFeature([GeneDetailModel, GenomeRequestModel]),
],
controllers: [GeneController],
providers: [GeneService],
exports: [GeneService],
})
export class GeneModule {}

View File

@@ -1,4 +1,129 @@
import { Injectable } from '@nestjs/common';
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { IsNull, Repository } from 'typeorm';
import { GeneDetailModel } from './entities/gene-detail.entity';
import { GenomeRequestModel } from '../genome/entities/genome-request.entity';
@Injectable()
export class GeneService {}
export class GeneService {
constructor(
@InjectRepository(GeneDetailModel)
private readonly geneDetailRepository: Repository<GeneDetailModel>,
@InjectRepository(GenomeRequestModel)
private readonly genomeRequestRepository: Repository<GenomeRequestModel>,
) {}
/**
* 개체식별번호(cowId)로 유전자 상세 정보 조회
* @param cowId 개체식별번호 (KOR...)
* @returns 유전자 상세 정보 배열
*/
async findByCowId(cowId: string): Promise<GeneDetailModel[]> {
const results = await this.geneDetailRepository.find({
where: {
cowId,
delDt: IsNull(),
},
relations: ['genomeRequest'],
order: {
chromosome: 'ASC',
position: 'ASC',
},
});
return results;
}
/**
* 의뢰번호(requestNo)로 유전자 상세 정보 조회
* @param requestNo 의뢰번호
* @returns 유전자 상세 정보 배열
*/
async findByRequestNo(requestNo: number): Promise<GeneDetailModel[]> {
const results = await this.geneDetailRepository.find({
where: {
fkRequestNo: requestNo,
delDt: IsNull(),
},
order: {
chromosome: 'ASC',
position: 'ASC',
},
});
return results;
}
/**
* 개체별 유전자 요약 정보 조회
* @param cowId 개체식별번호
* @returns 동형접합/이형접합 개수 요약
*/
async getGeneSummary(cowId: string): Promise<{
total: number;
homozygousCount: number;
heterozygousCount: number;
}> {
const geneDetails = await this.findByCowId(cowId);
let homozygousCount = 0;
let heterozygousCount = 0;
geneDetails.forEach((gene) => {
if (gene.allele1 && gene.allele2) {
if (gene.allele1 === gene.allele2) {
homozygousCount++;
} else {
heterozygousCount++;
}
}
});
return {
total: geneDetails.length,
homozygousCount,
heterozygousCount,
};
}
/**
* 유전자 상세 정보 단건 조회
* @param geneDetailNo 유전자상세번호
* @returns 유전자 상세 정보
*/
async findOne(geneDetailNo: number): Promise<GeneDetailModel> {
const result = await this.geneDetailRepository.findOne({
where: {
pkGeneDetailNo: geneDetailNo,
delDt: IsNull(),
},
relations: ['genomeRequest'],
});
if (!result) {
throw new NotFoundException(`유전자 상세 정보를 찾을 수 없습니다. (geneDetailNo: ${geneDetailNo})`);
}
return result;
}
/**
* 유전자 상세 정보 생성
* @param data 생성할 데이터
* @returns 생성된 유전자 상세 정보
*/
async create(data: Partial<GeneDetailModel>): Promise<GeneDetailModel> {
const geneDetail = this.geneDetailRepository.create(data);
return await this.geneDetailRepository.save(geneDetail);
}
/**
* 유전자 상세 정보 일괄 생성
* @param dataList 생성할 데이터 배열
* @returns 생성된 유전자 상세 정보 배열
*/
async createBulk(dataList: Partial<GeneDetailModel>[]): Promise<GeneDetailModel[]> {
const geneDetails = this.geneDetailRepository.create(dataList);
return await this.geneDetailRepository.save(geneDetails);
}
}

0
backend/src/genome/dto Normal file
View File

View File

@@ -17,6 +17,7 @@ COPY . .
# 빌드 시 필요한 환경 변수 설정
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production
ENV NEXT_PUBLIC_API_URL=/backend/api
# Next.js 빌드
RUN npm run build

View File

@@ -13,7 +13,7 @@ const nextConfig: NextConfig = {
return [
{
source: '/backend/api/:path*', // /api가 붙은 모든 요청
destination: 'http://backend:4000/:path*', // 백엔드 API로 요청
destination: 'http://192.168.11.249:4000/:path*', // 백엔드 API로 요청
},
];
},

View File

@@ -10,7 +10,7 @@ import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"
import { Badge } from "@/components/ui/badge"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { useToast } from "@/hooks/use-toast"
import { ComparisonAveragesDto, TraitComparisonAveragesDto, cowApi, genomeApi } from "@/lib/api"
import { ComparisonAveragesDto, TraitComparisonAveragesDto, cowApi, genomeApi, geneApi, GeneDetail } from "@/lib/api"
import { CowDetail } from "@/types/cow.types"
import { GenomeTrait } from "@/types/genome.types"
import { useGlobalFilter } from "@/contexts/GlobalFilterContext"
@@ -23,7 +23,10 @@ import {
Activity,
X,
XCircle,
Search,
} from 'lucide-react'
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { useEffect, useMemo, useRef, useState } from 'react'
import { CategoryEvaluationCard } from "./genome/_components/category-evaluation-card"
import { NormalDistributionChart } from "./genome/_components/normal-distribution-chart"
@@ -144,7 +147,7 @@ export default function CowOverviewPage() {
const [cow, setCow] = useState<CowDetail | null>(null)
const [genomeData, setGenomeData] = useState<GenomeTrait[]>([])
const [geneData, setGeneData] = useState<any[]>([])
const [geneData, setGeneData] = useState<GeneDetail[]>([])
const [loading, setLoading] = useState(true)
const [activeTab, setActiveTab] = useState<string>('genome')
@@ -193,6 +196,13 @@ export default function CowOverviewPage() {
// 차트 형질 필터 (전체 선발지수 또는 개별 형질)
const [chartFilterTrait, setChartFilterTrait] = useState<string>('overall')
// 유전자 탭 필터 상태
const [geneSearchKeyword, setGeneSearchKeyword] = useState('')
const [geneTypeFilter, setGeneTypeFilter] = useState<'all' | 'QTY' | 'QLT'>('all')
const [genotypeFilter, setGenotypeFilter] = useState<'all' | 'homozygous' | 'heterozygous'>('all')
const [geneSortBy, setGeneSortBy] = useState<'snpName' | 'chromosome' | 'position' | 'genotype'>('snpName')
const [geneSortOrder, setGeneSortOrder] = useState<'asc' | 'desc'>('asc')
// 농가/보은군 배지 클릭 시 차트로 스크롤 + 하이라이트
const handleComparisonClick = (mode: 'farm' | 'region') => {
// 토글: 같은 모드 클릭 시 해제
@@ -236,10 +246,17 @@ export default function CowOverviewPage() {
const genomeExists = genomeDataResult.length > 0 && !!genomeDataResult[0].genomeCows && genomeDataResult[0].genomeCows.length > 0
setHasGenomeData(genomeExists)
// 유전자(SNP) 데이터 가져오기 (gene.api 제거됨 - 추후 백엔드 구현 시 복구)
// TODO: gene API 구현 후 복구
setGeneData([])
setHasGeneData(false)
// 유전자(SNP) 데이터 가져오기
try {
const geneDataResult = await geneApi.findByCowId(cowNo)
setGeneData(geneDataResult)
setHasGeneData(geneDataResult.length > 0)
} catch (geneErr) {
console.error('유전자 데이터 조회 실패:', geneErr)
setGeneData([])
// UI 확인을 위해 임시로 true 설정 (데이터 없어도 UI는 보여줌)
setHasGeneData(true)
}
// 번식능력 데이터 (현재는 목업 - 추후 API 연동)
// TODO: 번식능력 API 연동
@@ -481,9 +498,6 @@ export default function CowOverviewPage() {
>
<Dna className="hidden sm:block h-6 w-6 shrink-0" />
<span className="font-bold text-sm sm:text-xl"></span>
<span className={`text-xs sm:text-sm px-1.5 sm:px-2.5 py-0.5 sm:py-1 rounded font-semibold shrink-0 ${hasGeneData ? 'bg-green-500 text-white' : 'bg-slate-300 text-slate-600'}`}>
{hasGeneData ? '완료' : '미검사'}
</span>
</TabsTrigger>
<TabsTrigger
value="reproduction"
@@ -491,9 +505,6 @@ export default function CowOverviewPage() {
>
<Activity className="hidden sm:block h-6 w-6 shrink-0" />
<span className="font-bold text-sm sm:text-xl"></span>
<span className={`text-xs sm:text-sm px-1.5 sm:px-2.5 py-0.5 sm:py-1 rounded font-semibold shrink-0 ${hasReproductionData ? 'bg-green-500 text-white' : 'bg-slate-300 text-slate-600'}`}>
{hasReproductionData ? '완료' : '미검사'}
</span>
</TabsTrigger>
</TabsList>
@@ -899,90 +910,533 @@ export default function CowOverviewPage() {
<TabsContent value="gene" className="mt-6 space-y-6">
{hasGeneData ? (
<>
<h3 className="text-lg lg:text-xl font-bold text-foreground">(SNP) </h3>
{/* 개체 정보 섹션 (유전체 탭과 동일) */}
<h3 className="text-lg lg:text-xl font-bold text-foreground"> </h3>
{/* 유전자 타입별 요약 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Card className="bg-blue-50 border-blue-200">
<CardContent className="p-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 bg-blue-500 rounded-lg flex items-center justify-center">
<Dna className="h-5 w-5 text-white" />
</div>
<div>
<h4 className="text-lg font-bold text-blue-900"> </h4>
<p className="text-sm text-blue-600">, </p>
</div>
</div>
<div className="space-y-2">
{geneData.filter(g => g.snpInfo?.markerSnps?.some((ms: any) => ms.marker?.markerTypeCd === 'QTY')).slice(0, 5).map((gene, idx) => (
<div key={idx} className="flex items-center justify-between py-2 border-b border-blue-200 last:border-0">
<span className="font-medium text-blue-800">{gene.snpInfo?.markerSnps?.[0]?.marker?.markerNm || gene.snpInfo?.snpNm}</span>
<Badge className="bg-blue-500 text-white">{gene.genotype || `${gene.allele1Top}${gene.allele2Top}`}</Badge>
</div>
))}
</div>
</CardContent>
</Card>
<Card className="bg-purple-50 border-purple-200">
<CardContent className="p-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 bg-purple-500 rounded-lg flex items-center justify-center">
<Dna className="h-5 w-5 text-white" />
</div>
<div>
<h4 className="text-lg font-bold text-purple-900"> </h4>
<p className="text-sm text-purple-600">, </p>
</div>
</div>
<div className="space-y-2">
{geneData.filter(g => g.snpInfo?.markerSnps?.some((ms: any) => ms.marker?.markerTypeCd === 'QLT')).slice(0, 5).map((gene, idx) => (
<div key={idx} className="flex items-center justify-between py-2 border-b border-purple-200 last:border-0">
<span className="font-medium text-purple-800">{gene.snpInfo?.markerSnps?.[0]?.marker?.markerNm || gene.snpInfo?.snpNm}</span>
<Badge className="bg-purple-500 text-white">{gene.genotype || `${gene.allele1Top}${gene.allele2Top}`}</Badge>
</div>
))}
</div>
</CardContent>
</Card>
</div>
{/* 전체 유전자 목록 */}
<h3 className="text-lg lg:text-xl font-bold text-foreground"> </h3>
<Card className="bg-white border border-border rounded-xl overflow-hidden">
<Card className="bg-white border border-border shadow-sm rounded-2xl overflow-hidden">
<CardContent className="p-0">
<div className="max-h-[400px] overflow-y-auto">
<table className="w-full">
<thead className="bg-muted/50 sticky top-0">
<tr>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground">SNP</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground"></th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground"></th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground"></th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{geneData.slice(0, 50).map((gene, idx) => (
<tr key={idx} className="hover:bg-muted/30">
<td className="px-4 py-3 text-sm font-medium">{gene.snpInfo?.snpNm || '-'}</td>
<td className="px-4 py-3 text-sm">{gene.snpInfo?.markerSnps?.[0]?.marker?.markerNm || '-'}</td>
<td className="px-4 py-3 text-sm">{gene.snpInfo?.chr || '-'}</td>
<td className="px-4 py-3">
<Badge variant="outline">{gene.genotype || `${gene.allele1Top || ''}${gene.allele2Top || ''}`}</Badge>
</td>
</tr>
))}
</tbody>
</table>
</div>
{geneData.length > 50 && (
<div className="px-4 py-3 bg-muted/30 text-center text-sm text-muted-foreground">
{geneData.length} 50
{/* 데스크탑: 가로 그리드 */}
<div className="hidden lg:grid lg:grid-cols-4 divide-x divide-border">
<div>
<div className="bg-muted/50 px-5 py-3 border-b border-border">
<span className="text-base font-semibold text-muted-foreground"></span>
</div>
<div className="px-5 py-4">
<CowNumberDisplay cowId={cowNo} variant="highlight" className="text-2xl font-bold text-foreground" />
</div>
</div>
)}
<div>
<div className="bg-muted/50 px-5 py-3 border-b border-border">
<span className="text-base font-semibold text-muted-foreground"></span>
</div>
<div className="px-5 py-4">
<span className="text-2xl font-bold text-foreground">
{cow?.cowBirthDt ? new Date(cow.cowBirthDt).toLocaleDateString('ko-KR') : '-'}
</span>
</div>
</div>
<div>
<div className="bg-muted/50 px-5 py-3 border-b border-border">
<span className="text-base font-semibold text-muted-foreground"></span>
</div>
<div className="px-5 py-4">
<span className="text-2xl font-bold text-foreground">
{cow?.cowBirthDt
? `${Math.floor((new Date().getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월`
: '-'}
</span>
</div>
</div>
<div>
<div className="bg-muted/50 px-5 py-3 border-b border-border">
<span className="text-base font-semibold text-muted-foreground"> </span>
</div>
<div className="px-5 py-4">
<span className="text-2xl font-bold text-foreground">
{genomeData[0]?.request?.chipReportDt
? new Date(genomeData[0].request.chipReportDt).toLocaleDateString('ko-KR')
: '-'}
</span>
</div>
</div>
</div>
{/* 모바일: 좌우 배치 리스트 */}
<div className="lg:hidden divide-y divide-border">
<div className="flex items-center">
<span className="w-28 shrink-0 bg-muted/50 px-4 py-3.5 text-base font-medium text-muted-foreground"></span>
<div className="flex-1 px-4 py-3.5">
<CowNumberDisplay cowId={cowNo} variant="highlight" className="text-base font-bold text-foreground" />
</div>
</div>
<div className="flex items-center">
<span className="w-28 shrink-0 bg-muted/50 px-4 py-3.5 text-base font-medium text-muted-foreground"></span>
<span className="flex-1 px-4 py-3.5 text-base font-bold text-foreground">
{cow?.cowBirthDt ? new Date(cow.cowBirthDt).toLocaleDateString('ko-KR') : '-'}
</span>
</div>
<div className="flex items-center">
<span className="w-28 shrink-0 bg-muted/50 px-4 py-3.5 text-base font-medium text-muted-foreground"></span>
<span className="flex-1 px-4 py-3.5 text-base font-bold text-foreground">
{cow?.cowBirthDt
? `${Math.floor((new Date().getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월`
: '-'}
</span>
</div>
<div className="flex items-center">
<span className="w-28 shrink-0 bg-muted/50 px-4 py-3.5 text-base font-medium text-muted-foreground"></span>
<span className="flex-1 px-4 py-3.5 text-base font-bold text-foreground">
{genomeData[0]?.request?.chipReportDt
? new Date(genomeData[0].request.chipReportDt).toLocaleDateString('ko-KR')
: '-'}
</span>
</div>
</div>
</CardContent>
</Card>
{/* 친자확인 결과 섹션 (유전체 탭과 동일) */}
<h3 className="text-lg lg:text-xl font-bold text-foreground"> </h3>
<Card className="bg-white border border-border shadow-sm rounded-2xl overflow-hidden">
<CardContent className="p-0">
{/* 데스크탑: 가로 그리드 */}
<div className="hidden lg:grid lg:grid-cols-2 divide-x divide-border">
<div>
<div className="bg-muted/50 px-5 py-3 border-b border-border">
<span className="text-base font-semibold text-muted-foreground"> KPN번호</span>
</div>
<div className="px-5 py-4 flex items-center justify-between gap-3">
<span className="text-2xl font-bold text-foreground break-all">
{cow?.sireKpn || '-'}
</span>
{(() => {
const chipSireName = genomeData[0]?.request?.chipSireName
if (chipSireName === '일치') {
return (
<span className="flex items-center gap-1.5 bg-primary text-primary-foreground text-sm font-semibold px-3 py-1.5 rounded-full shrink-0">
<CheckCircle2 className="w-4 h-4" />
<span></span>
</span>
)
} else if (chipSireName && chipSireName !== '일치') {
return (
<span className="flex items-center gap-1.5 bg-red-500 text-white text-sm font-semibold px-3 py-1.5 rounded-full shrink-0">
<XCircle className="w-4 h-4" />
<span></span>
</span>
)
} else {
return (
<span className="flex items-center gap-1.5 bg-slate-400 text-white text-sm font-semibold px-3 py-1.5 rounded-full shrink-0">
<span></span>
</span>
)
}
})()}
</div>
</div>
<div>
<div className="bg-muted/50 px-5 py-3 border-b border-border">
<span className="text-base font-semibold text-muted-foreground"> </span>
</div>
<div className="px-5 py-4 flex items-center justify-between gap-3">
{cow?.damCowId ? (
<CowNumberDisplay cowId={cow?.damCowId} variant="highlight" className="text-2xl font-bold text-foreground" />
) : (
<span className="text-2xl font-bold text-foreground">-</span>
)}
{(() => {
const chipDamName = genomeData[0]?.request?.chipDamName
if (chipDamName === '일치') {
return (
<span className="flex items-center gap-1.5 bg-primary text-primary-foreground text-sm font-semibold px-3 py-1.5 rounded-full shrink-0">
<CheckCircle2 className="w-4 h-4" />
<span></span>
</span>
)
} else if (chipDamName === '불일치') {
return (
<span className="flex items-center gap-1.5 bg-red-500 text-white text-sm font-semibold px-3 py-1.5 rounded-full shrink-0">
<XCircle className="w-4 h-4" />
<span></span>
</span>
)
} else if (chipDamName === '이력제부재') {
return (
<span className="flex items-center gap-1.5 bg-amber-500 text-white text-sm font-semibold px-3 py-1.5 rounded-full shrink-0">
<XCircle className="w-4 h-4" />
<span></span>
</span>
)
} else {
return null
}
})()}
</div>
</div>
</div>
{/* 모바일: 좌우 배치 리스트 */}
<div className="lg:hidden divide-y divide-border">
<div className="flex items-center">
<span className="w-28 shrink-0 bg-muted/50 px-4 py-3.5 text-base font-medium text-muted-foreground"> KPN번호</span>
<div className="flex-1 px-4 py-3.5 flex items-center justify-between gap-2">
<span className="text-base font-bold text-foreground break-all">
{cow?.sireKpn || '-'}
</span>
{(() => {
const chipSireName = genomeData[0]?.request?.chipSireName
if (chipSireName === '일치') {
return (
<span className="flex items-center gap-1 bg-primary text-primary-foreground text-xs font-semibold px-2 py-1 rounded-full shrink-0">
<CheckCircle2 className="w-3 h-3" />
<span></span>
</span>
)
} else if (chipSireName && chipSireName !== '일치') {
return (
<span className="flex items-center gap-1 bg-red-500 text-white text-xs font-semibold px-2 py-1 rounded-full shrink-0">
<XCircle className="w-3 h-3" />
<span></span>
</span>
)
} else {
return (
<span className="flex items-center gap-1 bg-slate-400 text-white text-xs font-semibold px-2 py-1 rounded-full shrink-0">
<span></span>
</span>
)
}
})()}
</div>
</div>
<div className="flex items-center">
<span className="w-28 shrink-0 bg-muted/50 px-4 py-3.5 text-base font-medium text-muted-foreground"> </span>
<div className="flex-1 px-4 py-3.5 flex items-center justify-between gap-2">
{cow?.damCowId ? (
<CowNumberDisplay cowId={cow?.damCowId} variant="highlight" className="text-base font-bold text-foreground" />
) : (
<span className="text-base font-bold text-foreground">-</span>
)}
{(() => {
const chipDamName = genomeData[0]?.request?.chipDamName
if (chipDamName === '일치') {
return (
<span className="flex items-center gap-1 bg-primary text-primary-foreground text-xs font-semibold px-2 py-1 rounded-full shrink-0">
<CheckCircle2 className="w-3 h-3" />
<span></span>
</span>
)
} else if (chipDamName === '불일치') {
return (
<span className="flex items-center gap-1 bg-red-500 text-white text-xs font-semibold px-2 py-1 rounded-full shrink-0">
<XCircle className="w-3 h-3" />
<span></span>
</span>
)
} else if (chipDamName === '이력제부재') {
return (
<span className="flex items-center gap-1 bg-amber-500 text-white text-xs font-semibold px-2 py-1 rounded-full shrink-0">
<XCircle className="w-3 h-3" />
<span></span>
</span>
)
} else {
return null
}
})()}
</div>
</div>
</div>
</CardContent>
</Card>
{/* 유전자 검색 및 필터 섹션 */}
<h3 className="text-lg lg:text-xl font-bold text-foreground"> </h3>
<div className="flex flex-col gap-3 sm:gap-3 p-3.5 max-sm:p-3 sm:px-4 sm:py-3 rounded-xl bg-slate-50/50 border border-slate-200/50">
{/* 검색창 */}
<div className="relative w-full">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<Input
placeholder="SNP명, 염색체 검색..."
className="pl-9 h-11 max-sm:h-10 text-base max-sm:text-sm border-slate-200 bg-white focus:border-blue-400 focus:ring-blue-100"
value={geneSearchKeyword}
onChange={(e) => setGeneSearchKeyword(e.target.value)}
/>
</div>
{/* 필터 옵션들 */}
<div className="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4 border-t border-slate-200/70 pt-3 sm:pt-3">
{/* 유전자 타입 필터 */}
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-slate-600 shrink-0">:</span>
<div className="flex bg-slate-100 rounded-lg p-1 gap-1">
<button
onClick={() => setGeneTypeFilter('all')}
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
geneTypeFilter === 'all'
? 'bg-white text-slate-900 shadow-sm'
: 'text-slate-500 hover:text-slate-700'
}`}
>
</button>
<button
onClick={() => setGeneTypeFilter('QTY')}
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
geneTypeFilter === 'QTY'
? 'bg-white text-slate-900 shadow-sm'
: 'text-slate-500 hover:text-slate-700'
}`}
>
</button>
<button
onClick={() => setGeneTypeFilter('QLT')}
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
geneTypeFilter === 'QLT'
? 'bg-white text-slate-900 shadow-sm'
: 'text-slate-500 hover:text-slate-700'
}`}
>
</button>
</div>
</div>
{/* 유전자형 필터 */}
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-slate-600 shrink-0">:</span>
<div className="flex bg-slate-100 rounded-lg p-1 gap-1">
<button
onClick={() => setGenotypeFilter('all')}
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
genotypeFilter === 'all'
? 'bg-white text-slate-900 shadow-sm'
: 'text-slate-500 hover:text-slate-700'
}`}
>
</button>
<button
onClick={() => setGenotypeFilter('homozygous')}
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
genotypeFilter === 'homozygous'
? 'bg-white text-slate-900 shadow-sm'
: 'text-slate-500 hover:text-slate-700'
}`}
>
</button>
<button
onClick={() => setGenotypeFilter('heterozygous')}
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
genotypeFilter === 'heterozygous'
? 'bg-white text-slate-900 shadow-sm'
: 'text-slate-500 hover:text-slate-700'
}`}
>
</button>
</div>
</div>
{/* 정렬 드롭다운 */}
<div className="flex items-center gap-2 sm:ml-auto">
<Select
value={geneSortBy}
onValueChange={(value: 'snpName' | 'chromosome' | 'position' | 'genotype') => setGeneSortBy(value)}
>
<SelectTrigger className="w-[110px] h-9 text-sm border-slate-200 bg-white">
<SelectValue placeholder="정렬 기준" />
</SelectTrigger>
<SelectContent>
<SelectItem value="snpName">SNP명</SelectItem>
<SelectItem value="chromosome"></SelectItem>
<SelectItem value="position"></SelectItem>
<SelectItem value="genotype"></SelectItem>
</SelectContent>
</Select>
<Select
value={geneSortOrder}
onValueChange={(value: 'asc' | 'desc') => setGeneSortOrder(value)}
>
<SelectTrigger className="w-[100px] h-9 text-sm border-slate-200 bg-white">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="asc"></SelectItem>
<SelectItem value="desc"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
{/* 유전자 테이블/카드 */}
{(() => {
const filteredData = geneData.filter(gene => {
// 검색 필터
if (geneSearchKeyword) {
const keyword = geneSearchKeyword.toLowerCase()
const snpName = (gene.snpName || '').toLowerCase()
const chromosome = (gene.chromosome || '').toLowerCase()
const position = (gene.position || '').toLowerCase()
if (!snpName.includes(keyword) && !chromosome.includes(keyword) && !position.includes(keyword)) {
return false
}
}
// 유전자형 필터
if (genotypeFilter !== 'all') {
const isHomozygous = gene.allele1 === gene.allele2
if (genotypeFilter === 'homozygous' && !isHomozygous) return false
if (genotypeFilter === 'heterozygous' && isHomozygous) return false
}
return true
})
// 정렬
const sortedData = [...filteredData].sort((a, b) => {
let aVal: string | number = ''
let bVal: string | number = ''
switch (geneSortBy) {
case 'snpName':
aVal = a.snpName || ''
bVal = b.snpName || ''
break
case 'chromosome':
aVal = parseInt(a.chromosome || '0') || 0
bVal = parseInt(b.chromosome || '0') || 0
break
case 'position':
aVal = parseInt(a.position || '0') || 0
bVal = parseInt(b.position || '0') || 0
break
case 'genotype':
aVal = `${a.allele1 || ''}${a.allele2 || ''}`
bVal = `${b.allele1 || ''}${b.allele2 || ''}`
break
}
if (typeof aVal === 'number' && typeof bVal === 'number') {
return geneSortOrder === 'asc' ? aVal - bVal : bVal - aVal
}
const strA = String(aVal)
const strB = String(bVal)
return geneSortOrder === 'asc'
? strA.localeCompare(strB)
: strB.localeCompare(strA)
})
const displayData = sortedData.length > 0
? sortedData.slice(0, 50)
: Array(10).fill(null)
return (
<>
{/* 데스크톱: 테이블 */}
<Card className="hidden lg:block bg-white border border-border shadow-sm rounded-2xl overflow-hidden">
<CardContent className="p-0">
<div>
<table className="w-full">
<thead className="bg-muted/50 border-b border-border">
<tr>
<th className="px-5 py-3 text-center text-base font-semibold text-muted-foreground">SNP </th>
<th className="px-5 py-3 text-center text-base font-semibold text-muted-foreground"> </th>
<th className="px-5 py-3 text-center text-base font-semibold text-muted-foreground">Position</th>
<th className="px-5 py-3 text-center text-base font-semibold text-muted-foreground">SNP </th>
<th className="px-5 py-3 text-center text-base font-semibold text-muted-foreground"> </th>
<th className="px-5 py-3 text-center text-base font-semibold text-muted-foreground"> </th>
<th className="px-5 py-3 text-center text-base font-semibold text-muted-foreground"></th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{displayData.map((gene, idx) => {
if (!gene) {
return (
<tr key={idx} className="hover:bg-muted/30">
<td className="px-5 py-4 text-base text-center text-muted-foreground">-</td>
<td className="px-5 py-4 text-base text-center text-muted-foreground">-</td>
<td className="px-5 py-4 text-base text-center text-muted-foreground">-</td>
<td className="px-5 py-4 text-base text-center text-muted-foreground">-</td>
<td className="px-5 py-4 text-base text-center text-muted-foreground">-</td>
<td className="px-5 py-4 text-base text-center text-muted-foreground">-</td>
<td className="px-5 py-4 text-base text-center text-muted-foreground">-</td>
</tr>
)
}
return (
<tr key={idx} className="hover:bg-muted/30">
<td className="px-5 py-4 text-center text-base font-medium text-foreground">{gene.snpName || '-'}</td>
<td className="px-5 py-4 text-center text-base text-foreground">{gene.chromosome || '-'}</td>
<td className="px-5 py-4 text-center text-base text-foreground">{gene.position || '-'}</td>
<td className="px-5 py-4 text-center text-base text-foreground">{gene.snpType || '-'}</td>
<td className="px-5 py-4 text-center text-base text-foreground">{gene.allele1 || '-'}</td>
<td className="px-5 py-4 text-center text-base text-foreground">{gene.allele2 || '-'}</td>
<td className="px-5 py-4 text-center text-base text-muted-foreground">{gene.remarks || '-'}</td>
</tr>
)
})}
</tbody>
</table>
</div>
{geneData.length > 50 && (
<div className="px-4 py-3 bg-muted/30 text-center text-sm text-muted-foreground border-t">
{geneData.length} 50
</div>
)}
</CardContent>
</Card>
{/* 모바일: 카드 뷰 */}
<div className="lg:hidden space-y-3">
{displayData.map((gene, idx) => {
return (
<Card key={idx} className="bg-white border border-border shadow-sm rounded-xl">
<CardContent className="p-4 space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">SNP </span>
<span className="text-base font-semibold text-foreground">{gene?.snpName || '-'}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground"> </span>
<span className="text-base text-foreground">{gene?.chromosome || '-'}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">Position</span>
<span className="text-base text-foreground">{gene?.position || '-'}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">SNP </span>
<span className="text-base text-foreground">{gene?.snpType || '-'}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground"> </span>
<span className="text-base text-foreground">{gene?.allele1 || '-'}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground"> </span>
<span className="text-base text-foreground">{gene?.allele2 || '-'}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground"></span>
<span className="text-base text-muted-foreground">{gene?.remarks || '-'}</span>
</div>
</CardContent>
</Card>
)
})}
{geneData.length > 50 && (
<div className="px-4 py-3 text-center text-sm text-muted-foreground">
{geneData.length} 50
</div>
)}
</div>
</>
)
})()}
</>
) : (
<Card className="bg-slate-50 border border-border rounded-2xl">

View File

@@ -1,40 +1,96 @@
/**
* Gene API (임시 Mock)
* TODO: 백엔드 구현 후 실제 API로 교체
* Gene API
* 유전자(SNP) 분석 결과 조회 API
*/
import apiClient from "../api-client";
export interface MarkerModel {
markerNm: string;
markerTypeCd: string; // 'QTY' | 'QLT'
markerDesc?: string;
relatedTrait?: string;
favorableAllele?: string;
/**
* 유전자 상세 정보 타입
*/
export interface GeneDetail {
pkGeneDetailNo: number;
fkRequestNo: number | null;
cowId: string | null;
snpName: string | null;
chromosome: string | null;
position: string | null;
snpType: string | null;
allele1: string | null;
allele2: string | null;
remarks: string | null;
regDt?: string;
updtDt?: string;
genomeRequest?: {
pkRequestNo: number;
requestDt: string | null;
chipReportDt: string | null;
chipSireName: string | null;
chipDamName: string | null;
};
}
/**
* 유전자 요약 정보 타입
*/
export interface GeneSummary {
total: number;
homozygousCount: number;
heterozygousCount: number;
}
export const geneApi = {
/**
* 전체 마커 목록 조회 (임시 빈 배열 반환)
* 개체식별번호로 유전자 상세 정보 조회
* GET /gene/:cowId
*/
getAllMarkers: async (): Promise<MarkerModel[]> => {
// TODO: 백엔드 구현 후 실제 API 연동
return [];
findByCowId: async (cowId: string): Promise<GeneDetail[]> => {
const response = await apiClient.get<GeneDetail[]>(`/gene/${cowId}`);
return response.data;
},
/**
* 타입별 마커 목록 조회 (임시 빈 배열 반환)
* 개체별 유전자 요약 정보 조회
* GET /gene/summary/:cowId
*/
getGenesByType: async (_typeCd: string): Promise<MarkerModel[]> => {
// TODO: 백엔드 구현 후 실제 API 연동
return [];
getGeneSummary: async (cowId: string): Promise<GeneSummary> => {
const response = await apiClient.get<GeneSummary>(`/gene/summary/${cowId}`);
return response.data;
},
/**
* 개체별 유전자(SNP) 데이터 조회 (임시 빈 배열 반환)
* 의뢰번호로 유전자 상세 정보 조회
* GET /gene/request/:requestNo
*/
findByCowNo: async (_cowNo: string | number): Promise<any[]> => {
// TODO: 백엔드 구현 후 실제 API 연동
return [];
findByRequestNo: async (requestNo: number): Promise<GeneDetail[]> => {
const response = await apiClient.get<GeneDetail[]>(`/gene/request/${requestNo}`);
return response.data;
},
/**
* 유전자 상세 정보 단건 조회
* GET /gene/detail/:geneDetailNo
*/
findOne: async (geneDetailNo: number): Promise<GeneDetail> => {
const response = await apiClient.get<GeneDetail>(`/gene/detail/${geneDetailNo}`);
return response.data;
},
/**
* 유전자 상세 정보 생성
* POST /gene
*/
create: async (data: Partial<GeneDetail>): Promise<GeneDetail> => {
const response = await apiClient.post<GeneDetail>('/gene', data);
return response.data;
},
/**
* 유전자 상세 정보 일괄 생성
* POST /gene/bulk
*/
createBulk: async (dataList: Partial<GeneDetail>[]): Promise<GeneDetail[]> => {
const response = await apiClient.post<GeneDetail[]>('/gene/bulk', dataList);
return response.data;
},
};

View File

@@ -13,6 +13,7 @@ export { authApi } from './auth.api'; // 인증 API
export { cowApi } from './cow.api';
export { dashboardApi } from './dashboard.api';
export { farmApi } from './farm.api';
export { geneApi, type GeneDetail, type GeneSummary } from './gene.api';
export { genomeApi, type ComparisonAveragesDto, type CategoryAverageDto, type FarmTraitComparisonDto, type TraitComparisonAveragesDto, type TraitAverageDto } from './genome.api';
export { reproApi } from './repro.api';
export { breedApi } from './breed.api';