Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/deploy-ec2-ssm.yml
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ jobs:
npm run test:external-lookup
npm run test:buildup-import
npm run test:marketing-inquiries
npm run test:customer-merge
npm run test:integration-contract
npm run typecheck
npm run security:audit
Expand Down
15 changes: 15 additions & 0 deletions .github/workflows/verify-direct-integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,24 @@ on:
- deploy/remote-deploy.sh
- .github/workflows/deploy-ec2-ssm.yml
- app/api/external/**
- app/api/customers/**
- app/api/migrate/find-duplicates/**
- app/(app)/customers/**
- app/(mobile)/m/customers/**
- app/(mobile)/m/layout.tsx
- proxy.ts
- components/CustomerMergePanel.tsx
- lib/customer-merge*.ts
- lib/customer-alias.ts
- lib/customer-mobile.ts
- prisma/schema.prisma
- deploy/schema-migrations/**
- lib/buildup-import/**
- lib/marketing-inquiries*.ts
- docs/integrations/**
- scripts/verify-direct-integration-contract.mjs
- package.json
- package-lock.json
- .github/workflows/verify-direct-integration.yml

permissions:
Expand All @@ -40,4 +53,6 @@ jobs:
npm run test:external-lookup
npm run test:buildup-import
npm run test:marketing-inquiries
python3 -m unittest discover -s deploy/tests -p 'test_*.py'
npm run test:customer-merge
npm run test:integration-contract
180 changes: 4 additions & 176 deletions app/(app)/admin/AdminClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@

import { useState } from 'react'
import { useRouter } from 'next/navigation'
import CustomerMergePanel from '@/components/CustomerMergePanel'
import {
RefreshCw,
Users, Database, Link2, Unlink,
GitMerge, Search, Phone, ChevronDown, ChevronUp,
Trash2, UserPlus, X, Pencil, FolderPlus, PackagePlus, Car, CreditCard, MapPin,
} from 'lucide-react'

Expand Down Expand Up @@ -80,16 +80,6 @@ interface GarageRow {
detail: string | null
}

type CustInfo = { id: string; name: string; phone: string | null; status: string; leadCount: number; createdAt: string }
type DupGroup = { phone: string | null; name: string; customers: CustInfo[] }

interface DupResult {
total: number
dupCount: number
groupCount: number
groups: DupGroup[]
}

interface ProductRow {
id: string
name: string
Expand All @@ -106,10 +96,10 @@ const PRODUCT_CATEGORIES = ['냉동', '상온', '특장', '기타']

export default function AdminClient({
stats, users: initialUsers, teams: initialTeams, products: initialProducts, vehicles: initialVehicles,
garages: initialGarages, corporateCards: initialCards, canManageUsers,
garages: initialGarages, corporateCards: initialCards, canManageUsers, canMergeCustomers,
}: {
stats: Stats; users: UserRow[]; teams: TeamRow[]; products: ProductRow[]; vehicles: VehicleRow[]
garages: GarageRow[]; corporateCards: CardRow[]; canManageUsers: boolean
garages: GarageRow[]; corporateCards: CardRow[]; canManageUsers: boolean; canMergeCustomers: boolean
}) {
const router = useRouter()

Expand Down Expand Up @@ -461,57 +451,6 @@ export default function AdminClient({
} finally { setEditLoading(false) }
}

/* 중복 진단 */
const [dupLoading, setDupLoading] = useState(false)
const [dupResult, setDupResult] = useState<DupResult | null>(null)
const [dupError, setDupError] = useState('')
const [expanded, setExpanded] = useState<Set<number>>(new Set())
const [merging, setMerging] = useState<string | null>(null) // removeId being merged
const [mergeMsg, setMergeMsg] = useState('')

/* ── 핸들러: 중복 고객 검색 ── */
const handleFindDups = async () => {
setDupLoading(true)
setDupError('')
setDupResult(null)
setMergeMsg('')
try {
const res = await fetch('/api/migrate/find-duplicates')
const json = await res.json()
if (!res.ok) { setDupError(json.error ?? '오류'); return }
setDupResult(json)
} catch {
setDupError('네트워크 오류가 발생했습니다')
} finally {
setDupLoading(false)
}
}

/* ── 핸들러: 고객 통합 ── */
const handleMerge = async (keepId: string, removeId: string) => {
setMerging(removeId)
setMergeMsg('')
try {
const res = await fetch('/api/migrate/merge-customers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ keepId, removeId }),
})
const json = await res.json()
if (!res.ok) { setMergeMsg(`오류: ${json.error}`); return }
setMergeMsg(json.message)
// 중복 목록 갱신
await handleFindDups()
router.refresh()
} catch {
setMergeMsg('네트워크 오류가 발생했습니다')
} finally {
setMerging(null)
}
}

const toggleGroup = (idx: number) =>
setExpanded(prev => { const s = new Set(prev); s.has(idx) ? s.delete(idx) : s.add(idx); return s })

/* ── 렌더 ── */
return (
Expand Down Expand Up @@ -1700,118 +1639,7 @@ export default function AdminClient({
))}
</div>

{/* ══ 중복 고객 통합 ══ */}
<div className="bg-white border border-slate-200 rounded-2xl overflow-hidden">
<div className="px-6 py-4 bg-indigo-900">
<div className="flex items-center justify-between">
<div>
<h2 className="text-white font-bold text-sm flex items-center gap-2">
<GitMerge size={15} /> 중복 고객 통합
</h2>
<p className="text-indigo-300 text-xs mt-0.5">동일 전화번호로 중복 등록된 고객을 찾아 하나로 합칩니다</p>
</div>
<button onClick={handleFindDups} disabled={dupLoading}
className="flex items-center gap-1.5 px-4 py-2 bg-white/10 hover:bg-white/20 text-white text-xs font-semibold rounded-lg transition disabled:opacity-50">
{dupLoading ? <RefreshCw size={12} className="animate-spin" /> : <Search size={12} />}
{dupLoading ? '검색 중...' : '중복 검색'}
</button>
</div>
</div>

<div className="p-6">
{dupError && <p className="text-sm text-red-600 mb-4">{dupError}</p>}
{mergeMsg && (
<div className="mb-4 px-4 py-2.5 bg-emerald-50 border border-emerald-200 rounded-xl text-xs text-emerald-700 font-medium">
✓ {mergeMsg}
</div>
)}

{!dupResult && !dupLoading && (
<p className="text-sm text-slate-400 text-center py-6">
"중복 검색" 버튼을 누르면 전화번호가 동일한 고객을 찾아줍니다
</p>
)}

{dupResult && (
<>
{/* 요약 */}
<div className={`mb-5 px-4 py-3 rounded-xl border text-sm font-semibold ${
dupResult.dupCount > 0
? 'bg-red-50 border-red-200 text-red-700'
: 'bg-emerald-50 border-emerald-200 text-emerald-700'
}`}>
{dupResult.dupCount > 0
? `전체 ${dupResult.total}명 중 중복 ${dupResult.groupCount}건 발견 — 제거 가능 레코드 ${dupResult.dupCount}개`
: `전체 ${dupResult.total}명 검사 완료 — 중복 없음`}
</div>

{/* 중복 그룹 목록 */}
{dupResult.groups.length > 0 && (
<div className="space-y-3">
{dupResult.groups.map((group, idx) => (
<div key={idx} className="border border-slate-200 rounded-xl overflow-hidden">
{/* 그룹 헤더 */}
<button onClick={() => toggleGroup(idx)}
className="w-full flex items-center justify-between px-4 py-3 bg-slate-50 hover:bg-slate-100 transition text-left">
<div className="flex items-center gap-3">
<Phone size={12} className="text-slate-400" />
<span className="text-sm font-semibold text-slate-700">{group.name}</span>
<span className="text-xs text-slate-400">{group.phone ?? '전화 없음'}</span>
<span className="px-2 py-0.5 bg-red-100 text-red-600 text-[10px] font-bold rounded">
{group.customers.length}개 중복
</span>
</div>
{expanded.has(idx) ? <ChevronUp size={14} className="text-slate-400" /> : <ChevronDown size={14} className="text-slate-400" />}
</button>

{/* 펼친 상태: 통합 선택 */}
{expanded.has(idx) && (
<div className="divide-y divide-slate-100">
<div className="px-4 py-2 bg-blue-50">
<p className="text-[11px] text-blue-600 font-semibold">
아래에서 "남길 고객"을 결정하고, 나머지의 "통합" 버튼을 누르세요.
리드·활동이 모두 남긴 고객으로 이전됩니다.
</p>
</div>
{group.customers.map((c, ci) => {
const isFirst = ci === 0
const otherIds = group.customers.filter(x => x.id !== c.id).map(x => x.id)
const keepId = isFirst ? c.id : group.customers[0].id
return (
<div key={c.id} className={`px-4 py-3 flex items-center justify-between ${isFirst ? 'bg-emerald-50/40' : ''}`}>
<div className="flex items-center gap-3 text-xs">
{isFirst && <span className="px-1.5 py-0.5 bg-emerald-100 text-emerald-700 font-bold text-[9px] rounded">기준</span>}
<div>
<p className="font-semibold text-slate-700">{c.name}</p>
<p className="text-slate-400 mt-0.5">
{c.phone ?? '전화 없음'} · {c.status} ·
리드 {c.leadCount}건 · {c.createdAt.slice(0, 10)} 생성
</p>
</div>
</div>
{!isFirst && (
<button
onClick={() => handleMerge(keepId, c.id)}
disabled={merging === c.id}
className="shrink-0 ml-4 px-3 py-1.5 text-xs font-bold rounded-lg bg-indigo-600 text-white hover:bg-indigo-700 transition disabled:opacity-50 flex items-center gap-1.5">
{merging === c.id
? <><RefreshCw size={10} className="animate-spin" />통합 중</>
: <><GitMerge size={10} />기준으로 통합</>}
</button>
)}
</div>
)
})}
</div>
)}
</div>
))}
</div>
)}
</>
)}
</div>
</div>
{canMergeCustomers && <CustomerMergePanel />}

</div>
)
Expand Down
5 changes: 5 additions & 0 deletions app/(app)/admin/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ import AdminClient from './AdminClient'
export default async function AdminPage() {
const session = await auth()
const canManageUsersFlag = await canManageUsers((session?.user as any)?.id)
const actor = session?.user?.id ? await prisma.user.findUnique({
where: { id: session.user.id }, select: { role: true, employmentType: true },
}) : null
const canMergeCustomers = !!actor && ['admin', 'ceo'].includes(actor.role) && actor.employmentType !== '사외'

const [
totalCustomers, linkedDeals, unlinkedDeals, customersWithDetail,
Expand Down Expand Up @@ -57,6 +61,7 @@ export default async function AdminPage() {
garages={garages}
corporateCards={corporateCards.map(c => ({ ...c, createdAt: c.createdAt.toISOString() }))}
canManageUsers={canManageUsersFlag}
canMergeCustomers={canMergeCustomers}
/>
)
}
34 changes: 27 additions & 7 deletions app/(app)/customers/CustomerListClient.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
'use client'

import { useState, useMemo, useEffect, useRef } from 'react'
import { useState, useMemo, useEffect, useRef, useCallback } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { Phone, Truck } from 'lucide-react'
import { BuildupImportModal } from '@/components/BuildupImportModal'
import { countDuplicateCustomers } from '@/lib/customer-mobile'

/**
* "미상(...)" 이름에 박아둔 이모지 아이콘(📞/🚚)을 전화/차량번호 바로 앞에
Expand Down Expand Up @@ -175,9 +176,9 @@ function fmtDate(iso: string | null) {
return new Date(iso).toLocaleDateString('ko-KR', { month: '2-digit', day: '2-digit' }).replace('. ', '/').replace('.', '')
}

interface Props { customers: Customer[] }
interface Props { customers: Customer[]; canMergeCustomers?: boolean }

export default function CustomerListClient({ customers: initial }: Props) {
export default function CustomerListClient({ customers: initial, canMergeCustomers = false }: Props) {
const router = useRouter()
const [customers, setCustomers] = useState<Customer[]>(initial)
const [seg, setSeg] = useState<Seg>('all')
Expand All @@ -188,10 +189,19 @@ export default function CustomerListClient({ customers: initial }: Props) {
const [importOpen, setImportOpen] = useState(false)

/** buildup 가져오기 승인 후 목록 재조회 — props 초기값이라 router.refresh 로는 안 바뀐다 */
const reloadCustomers = async () => {
const res = await fetch('/api/customers')
if (res.ok) setCustomers(await res.json())
}
const reloadCustomers = useCallback(async () => {
try {
const res = await fetch('/api/customers', { cache: 'no-store' })
if (res.ok) setCustomers(await res.json())
} catch { /* Retain the last known list when offline. */ }
}, [])

useEffect(() => {
if (!canMergeCustomers) return
void reloadCustomers()
window.addEventListener('focus', reloadCustomers)
return () => window.removeEventListener('focus', reloadCustomers)
}, [canMergeCustomers, reloadCustomers])

/* 모달 없이 바로 고객상세 페이지로 이동 — 이름은 비워둔 채 생성 후 상세페이지에서 채움 */
const handleQuickAdd = async () => {
Expand Down Expand Up @@ -232,6 +242,7 @@ export default function CustomerListClient({ customers: initial }: Props) {

const b2cCount = useMemo(() => customers.filter(c => (c.customerSegment ?? 'B2C') === 'B2C').length, [customers])
const b2bCount = useMemo(() => customers.filter(c => c.customerSegment === 'B2B').length, [customers])
const duplicateCount = useMemo(() => countDuplicateCustomers(customers), [customers])

const filtered = useMemo(() => {
return customers.filter(c => {
Expand Down Expand Up @@ -326,6 +337,15 @@ export default function CustomerListClient({ customers: initial }: Props) {
buildup에서 불러오기
</button>

{canMergeCustomers && <Link href="/customers/duplicates"
title={`처리할 중복 고객 ${duplicateCount}건`}
className="relative rounded-lg border border-slate-300 px-3 py-2 text-xs font-semibold text-slate-700 hover:bg-slate-50">
중복 고객 확인
{duplicateCount > 0 && <span role="status" aria-label={`처리할 중복 고객 ${duplicateCount}건`}
className="absolute -right-2 -top-2 flex h-5 min-w-5 items-center justify-center rounded-full bg-red-600 px-1 text-[10px] font-bold leading-none text-white ring-2 ring-white tabular-nums">
{duplicateCount > 99 ? '99+' : duplicateCount}
</span>}
</Link>}
{/* 신규 고객 추가 */}
<Link href="/import"
className="px-3 py-1.5 text-xs font-semibold rounded-lg border border-slate-300 text-slate-600 hover:bg-slate-50 transition">
Expand Down
Loading
Loading