diff --git a/.github/workflows/deploy-ec2-ssm.yml b/.github/workflows/deploy-ec2-ssm.yml index 7aee0c6..fcf8d3b 100644 --- a/.github/workflows/deploy-ec2-ssm.yml +++ b/.github/workflows/deploy-ec2-ssm.yml @@ -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 diff --git a/.github/workflows/verify-direct-integration.yml b/.github/workflows/verify-direct-integration.yml index 1a45776..6afdc69 100644 --- a/.github/workflows/verify-direct-integration.yml +++ b/.github/workflows/verify-direct-integration.yml @@ -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: @@ -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 diff --git a/app/(app)/admin/AdminClient.tsx b/app/(app)/admin/AdminClient.tsx index 2ab4cff..bae5112 100644 --- a/app/(app)/admin/AdminClient.tsx +++ b/app/(app)/admin/AdminClient.tsx @@ -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' @@ -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 @@ -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() @@ -461,57 +451,6 @@ export default function AdminClient({ } finally { setEditLoading(false) } } - /* 중복 진단 */ - const [dupLoading, setDupLoading] = useState(false) - const [dupResult, setDupResult] = useState(null) - const [dupError, setDupError] = useState('') - const [expanded, setExpanded] = useState>(new Set()) - const [merging, setMerging] = useState(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 ( @@ -1700,118 +1639,7 @@ export default function AdminClient({ ))} - {/* ══ 중복 고객 통합 ══ */} -
-
-
-
-

- 중복 고객 통합 -

-

동일 전화번호로 중복 등록된 고객을 찾아 하나로 합칩니다

-
- -
-
- -
- {dupError &&

{dupError}

} - {mergeMsg && ( -
- ✓ {mergeMsg} -
- )} - - {!dupResult && !dupLoading && ( -

- "중복 검색" 버튼을 누르면 전화번호가 동일한 고객을 찾아줍니다 -

- )} - - {dupResult && ( - <> - {/* 요약 */} -
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}명 검사 완료 — 중복 없음`} -
- - {/* 중복 그룹 목록 */} - {dupResult.groups.length > 0 && ( -
- {dupResult.groups.map((group, idx) => ( -
- {/* 그룹 헤더 */} - - - {/* 펼친 상태: 통합 선택 */} - {expanded.has(idx) && ( -
-
-

- 아래에서 "남길 고객"을 결정하고, 나머지의 "통합" 버튼을 누르세요. - 리드·활동이 모두 남긴 고객으로 이전됩니다. -

-
- {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 ( -
-
- {isFirst && 기준} -
-

{c.name}

-

- {c.phone ?? '전화 없음'} · {c.status} · - 리드 {c.leadCount}건 · {c.createdAt.slice(0, 10)} 생성 -

-
-
- {!isFirst && ( - - )} -
- ) - })} -
- )} -
- ))} -
- )} - - )} -
-
+ {canMergeCustomers && } ) diff --git a/app/(app)/admin/page.tsx b/app/(app)/admin/page.tsx index 0faa442..bf05444 100644 --- a/app/(app)/admin/page.tsx +++ b/app/(app)/admin/page.tsx @@ -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, @@ -57,6 +61,7 @@ export default async function AdminPage() { garages={garages} corporateCards={corporateCards.map(c => ({ ...c, createdAt: c.createdAt.toISOString() }))} canManageUsers={canManageUsersFlag} + canMergeCustomers={canMergeCustomers} /> ) } diff --git a/app/(app)/customers/CustomerListClient.tsx b/app/(app)/customers/CustomerListClient.tsx index c0c2ee2..2c5d345 100644 --- a/app/(app)/customers/CustomerListClient.tsx +++ b/app/(app)/customers/CustomerListClient.tsx @@ -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' /** * "미상(...)" 이름에 박아둔 이모지 아이콘(📞/🚚)을 전화/차량번호 바로 앞에 @@ -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(initial) const [seg, setSeg] = useState('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 () => { @@ -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 => { @@ -326,6 +337,15 @@ export default function CustomerListClient({ customers: initial }: Props) { buildup에서 불러오기 + {canMergeCustomers && + 중복 고객 확인 + {duplicateCount > 0 && + {duplicateCount > 99 ? '99+' : duplicateCount} + } + } {/* 신규 고객 추가 */} diff --git a/app/(app)/customers/[id]/CustomerDetailClient.tsx b/app/(app)/customers/[id]/CustomerDetailClient.tsx index 8bf58c5..7a01053 100644 --- a/app/(app)/customers/[id]/CustomerDetailClient.tsx +++ b/app/(app)/customers/[id]/CustomerDetailClient.tsx @@ -82,12 +82,16 @@ type Customer = { shipperName: string | null; cargoType: string | null deliveryCity: string | null; deliveryDist: string | null; deliveryFreq: string | null workShift: string | null; monthlyIncome: string | null; cargoNote: string | null - collectedAt: string | null; createdAt: string + collectedAt: string | null; createdAt: string; updatedAt: string leads: Lead[]; activities: Activity[] } -export default function CustomerDetailClient({ customer, returnTo, myName }: { customer: Customer; returnTo?: string; myName?: string }) { +export default function CustomerDetailClient({ customer, returnTo, myName, backHref = '/customers' }: { + customer: Customer; returnTo?: string; myName?: string; backHref?: string +}) { const router = useRouter() + // Keep the version that belongs to this form; a prop refresh must not bless an old draft. + const [expectedUpdatedAt, setExpectedUpdatedAt] = useState(customer.updatedAt) const [f, setF] = useState({ /* 기본 */ @@ -175,14 +179,23 @@ export default function CustomerDetailClient({ customer, returnTo, myName }: { c fd.append('type', docType) const res = await fetch(`/api/customers/${customer.id}/documents`, { method: 'POST', body: fd }) const data = await res.json() - setDocs(prev => [...prev.filter(d => d.type !== docType), data.doc]) + if (!res.ok) { setMsg(data.error ?? '파일을 업로드하지 못했습니다.'); return } + setDocs(prev => Array.isArray(data.docs) ? data.docs : [...prev, data.doc]) + } catch { + setMsg('파일 업로드 결과를 확인하지 못했습니다. 새로고침 후 확인해 주세요.') } finally { setUploading(null) } } const handleDocDelete = async (docType: string) => { if (!confirm(`"${docType}" 파일을 삭제할까요?`)) return - await fetch(`/api/customers/${customer.id}/documents?type=${encodeURIComponent(docType)}`, { method: 'DELETE' }) - setDocs(prev => prev.filter(d => d.type !== docType)) + try { + const res = await fetch(`/api/customers/${customer.id}/documents?type=${encodeURIComponent(docType)}`, { method: 'DELETE' }) + const data = await res.json() + if (!res.ok) { setMsg(data.error ?? '파일을 삭제하지 못했습니다.'); return } + setDocs(prev => prev.filter(d => d.type !== docType)) + } catch { + setMsg('파일 삭제 결과를 확인하지 못했습니다. 새로고침 후 확인해 주세요.') + } } /* B2B 보유차량 목록 */ @@ -210,13 +223,22 @@ export default function CustomerDetailClient({ customer, returnTo, myName }: { c fd.append('file', file) const res = await fetch(`/api/customers/${customer.id}/contact-card`, { method: 'POST', body: fd }) const data = await res.json() + if (!res.ok) { setMsg(data.error ?? '명함을 업로드하지 못했습니다.'); return } setMainCardUrl(data.url) + } catch { + setMsg('명함 업로드 결과를 확인하지 못했습니다. 새로고침 후 확인해 주세요.') } finally { setMainCardUploading(false) } } const handleMainCardDelete = async () => { if (!confirm('명함 이미지를 삭제할까요?')) return - await fetch(`/api/customers/${customer.id}/contact-card`, { method: 'DELETE' }) - setMainCardUrl(null) + try { + const res = await fetch(`/api/customers/${customer.id}/contact-card`, { method: 'DELETE' }) + const data = await res.json() + if (!res.ok) { setMsg(data.error ?? '명함을 삭제하지 못했습니다.'); return } + setMainCardUrl(null) + } catch { + setMsg('명함 삭제 결과를 확인하지 못했습니다. 새로고침 후 확인해 주세요.') + } } /* B2B 관계자 목록 */ @@ -329,6 +351,7 @@ export default function CustomerDetailClient({ customer, returnTo, myName }: { c method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(Object.assign({}, f, { + expectedUpdatedAt, phone: f.phone || null, email: f.email || null, grade: f.grade || null, @@ -356,7 +379,7 @@ export default function CustomerDetailClient({ customer, returnTo, myName }: { c companyAddress: f.companyAddress || null, companyPhone: f.companyPhone || null, employeeCount: f.employeeCount ? parseInt(f.employeeCount.replace(/,/g, ''), 10) : null, - contactsJson: f.customerSegment === 'B2B' ? JSON.stringify(contacts) : null, + contactsJson: f.customerSegment === 'B2B' ? JSON.stringify(contacts) : customer.contactsJson, b2bRevenue1: f.b2bRevenue1 || null, b2bRevenue2: f.b2bRevenue2 || null, b2bRevenue3: f.b2bRevenue3 || null, @@ -365,7 +388,7 @@ export default function CustomerDetailClient({ customer, returnTo, myName }: { c totalMileage: f.totalMileage ? parseInt(f.totalMileage.replace(/,/g, ''), 10) : null, vehicleListJson: f.customerSegment === 'B2B' ? JSON.stringify(vehicleList.filter(r => r.name || r.count)) - : null, + : customer.vehicleListJson, vehicleMaker: (makerChip === '직접입력' ? makerCustom : makerChip) || null, vehicleName: f.vehicleName || null, vehiclePlateNo: f.vehiclePlateNo || null, @@ -389,6 +412,8 @@ export default function CustomerDetailClient({ customer, returnTo, myName }: { c alert(err.error || '저장에 실패했습니다') return } + const updated = await res.json() + setExpectedUpdatedAt(updated.updatedAt) setSaved(true) setMsg('저장되었습니다') setTimeout(() => setMsg(''), 2500) @@ -438,7 +463,7 @@ export default function CustomerDetailClient({ customer, returnTo, myName }: { c }), }) const deal = await res.json() - router.push(`/funnel/${deal.id}`) + router.push(`${backHref.startsWith('/m/') ? '/m/pipeline' : '/funnel'}/${deal.id}`) } catch { setConverting(false) } @@ -472,7 +497,7 @@ export default function CustomerDetailClient({ customer, returnTo, myName }: { c ) return ( -
+
{msg && (
✓ {msg} @@ -496,12 +521,12 @@ export default function CustomerDetailClient({ customer, returnTo, myName }: { c )} {/* 헤더 */} -
-
- ← 고객 목록 -
-

{f.name || '이름 미입력'}

-
+
+
+ ← 고객 목록 +
+

{f.name || '이름 미입력'}

+
{customer.phone && {customer.phone}}
-
+
)} + {docs.length > 0 && ( +
+ {sectionHead('slate', '전체 첨부자료')} +
+

같은 종류의 파일도 각각 보관합니다. 아래에서 원본을 확인할 수 있습니다.

+ {docs.map((doc, i) =>
+ {doc.type} + {doc.name} + {doc.uploadedAt?.slice(0, 10)} +
)} +
+
+ )} {/* 메모 (공통) */}
{sectionHead('slate', '메모')} @@ -1118,7 +1161,7 @@ export default function CustomerDetailClient({ customer, returnTo, myName }: { c
{customer.leads.map(lead => ( -
searchParams: Promise<{ returnTo?: string }> }) { - const { id } = await params + const { id: requestedId } = await params + const id = await resolveCustomerId(prisma, requestedId) const { returnTo } = await searchParams const session = await auth() const me = session?.user as any @@ -23,5 +25,9 @@ export default async function CustomerDetailPage({ }) if (!customer) notFound() if (me?.employmentType === '사외' && customer.assignee !== me?.name) notFound() + if (id !== requestedId) { + const query = returnTo ? `?${new URLSearchParams({ returnTo })}` : '' + redirect(`/customers/${encodeURIComponent(id)}${query}`) + } return } diff --git a/app/(app)/customers/duplicates/page.tsx b/app/(app)/customers/duplicates/page.tsx new file mode 100644 index 0000000..f1a1e93 --- /dev/null +++ b/app/(app)/customers/duplicates/page.tsx @@ -0,0 +1,18 @@ +import Link from 'next/link' +import { auth } from '@/auth' +import { prisma } from '@/lib/db' +import CustomerMergePanel from '@/components/CustomerMergePanel' + +export default async function CustomerDuplicatesPage() { + const session = await auth() + const actor = session?.user?.id ? await prisma.user.findUnique({ + where: { id: session.user.id }, select: { role: true, employmentType: true }, + }) : null + if (!actor || !['admin', 'ceo'].includes(actor.role) || actor.employmentType === '사외') { + return
중복 고객 통합은 관리자·대표 계정에서 사용할 수 있습니다.
+ } + return
+ ← 고객 관리로 돌아가기 + +
+} diff --git a/app/(app)/customers/page.tsx b/app/(app)/customers/page.tsx index bba27f1..bf7a6d2 100644 --- a/app/(app)/customers/page.tsx +++ b/app/(app)/customers/page.tsx @@ -7,6 +7,10 @@ export default async function CustomersPage() { const session = await auth() const me = session?.user as any const isExternal = me?.employmentType === '사외' + const actor = me?.id ? await prisma.user.findUnique({ + where: { id: me.id }, select: { role: true, employmentType: true }, + }) : null + const canMergeCustomers = !!actor && ['admin', 'ceo'].includes(actor.role) && actor.employmentType !== '사외' const customers = await prisma.customer.findMany({ where: isExternal ? { assignee: me?.name ?? '__none__' } : undefined, @@ -30,7 +34,7 @@ export default async function CustomersPage() { {/* buildup 미처리 이벤트 배지 — 이 화면에 온 사람에게만 보인다 (#27) */} - +
) } diff --git a/app/(mobile)/m/customers/MobileCustomersClient.tsx b/app/(mobile)/m/customers/MobileCustomersClient.tsx new file mode 100644 index 0000000..4325736 --- /dev/null +++ b/app/(mobile)/m/customers/MobileCustomersClient.tsx @@ -0,0 +1,165 @@ +'use client' + +import { useCallback, useEffect, useMemo, useState } from 'react' +import Link from 'next/link' +import { ChevronRight, GitMerge, Phone, Search } from 'lucide-react' +import { countDuplicateCustomers } from '@/lib/customer-mobile' + +type Customer = { + id: string + name: string + phone: string | null + status: string + customerSegment: string | null + assignee: string | null + leads: { id: string }[] +} + +type Segment = 'all' | 'B2C' | 'B2B' + +export default function MobileCustomersClient({ + customers: initial, + canMergeCustomers, + canRefresh, +}: { + customers: Customer[] + canMergeCustomers: boolean + canRefresh: boolean +}) { + const [customers, setCustomers] = useState(initial) + const [search, setSearch] = useState('') + const [segment, setSegment] = useState('all') + + const reloadCustomers = useCallback(async () => { + if (!canRefresh) return + try { + const response = await fetch('/api/customers', { cache: 'no-store' }) + if (response.ok) setCustomers(await response.json()) + } catch { /* Keep the last known list while offline. */ } + }, [canRefresh]) + + useEffect(() => { + const refresh = window.setTimeout(reloadCustomers, 0) + window.addEventListener('focus', reloadCustomers) + return () => { + window.clearTimeout(refresh) + window.removeEventListener('focus', reloadCustomers) + } + }, [reloadCustomers]) + + const filtered = useMemo(() => { + const query = search.trim().toLocaleLowerCase('ko-KR') + const digits = query.replace(/\D/g, '') + return customers.filter(customer => { + const customerSegment = customer.customerSegment ?? 'B2C' + if (segment !== 'all' && customerSegment !== segment) return false + if (!query) return true + return customer.name.toLocaleLowerCase('ko-KR').includes(query) + || (customer.phone ?? '').toLocaleLowerCase('ko-KR').includes(query) + || (!!digits && (customer.phone ?? '').replace(/\D/g, '').includes(digits)) + }) + }, [customers, search, segment]) + + const duplicateCount = canMergeCustomers ? countDuplicateCustomers(customers) : 0 + + return ( +
+
+
+
+

고객 관리

+

전체 {customers.length}명 · 표시 {filtered.length}명

+
+ {canMergeCustomers && ( + + + 중복 확인 + {duplicateCount > 0 && ( + + {duplicateCount > 99 ? '99+' : duplicateCount} + + )} + + )} +
+ + + +
+ {([ + ['all', '전체'], + ['B2C', 'B2C'], + ['B2B', 'B2B'], + ] as const).map(([value, label]) => ( + + ))} +
+
+ +
+ {filtered.length === 0 && ( +
+ 조건에 맞는 고객이 없습니다. +
+ )} + {filtered.map(customer => ( + +
+
+ {customer.name || '이름 미입력'} + + {customer.customerSegment ?? 'B2C'} + +
+
+ + {customer.phone || '연락처 없음'} +
+
+ {customer.status} + 리드 {customer.leads.length}건 +
+
+ + + ))} +
+
+ ) +} diff --git a/app/(mobile)/m/customers/[id]/page.tsx b/app/(mobile)/m/customers/[id]/page.tsx new file mode 100644 index 0000000..9b72b1a --- /dev/null +++ b/app/(mobile)/m/customers/[id]/page.tsx @@ -0,0 +1,39 @@ +import { notFound, redirect } from 'next/navigation' +import { auth } from '@/auth' +import { prisma } from '@/lib/db' +import { resolveCustomerId } from '@/lib/customer-alias' +import CustomerDetailClient from '@/app/(app)/customers/[id]/CustomerDetailClient' + +export default async function MobileCustomerDetailPage({ + params, +}: { + params: Promise<{ id: string }> +}) { + const { id: requestedId } = await params + const id = await resolveCustomerId(prisma, requestedId) + const session = await auth() + if (!session?.user?.id) redirect('/login') + const me = await prisma.user.findUnique({ + where: { id: session.user.id }, select: { name: true, employmentType: true }, + }) + if (!me) redirect('/login') + const customer = await prisma.customer.findUnique({ + where: { id }, + include: { + leads: { orderBy: { createdAt: 'desc' } }, + activities: { orderBy: { date: 'desc' } }, + }, + }) + + if (!customer) notFound() + if (me.employmentType === '사외' && customer.assignee !== me.name) notFound() + if (id !== requestedId) redirect(`/m/customers/${encodeURIComponent(id)}`) + + return ( + + ) +} diff --git a/app/(mobile)/m/customers/duplicates/page.tsx b/app/(mobile)/m/customers/duplicates/page.tsx new file mode 100644 index 0000000..e35b4bf --- /dev/null +++ b/app/(mobile)/m/customers/duplicates/page.tsx @@ -0,0 +1,25 @@ +import Link from 'next/link' +import { auth } from '@/auth' +import { prisma } from '@/lib/db' +import CustomerMergePanel from '@/components/CustomerMergePanel' + +export default async function MobileCustomerDuplicatesPage() { + const session = await auth() + const actor = session?.user?.id ? await prisma.user.findUnique({ + where: { id: session.user.id }, + select: { role: true, employmentType: true }, + }) : null + + if (!actor || !['admin', 'ceo'].includes(actor.role) || actor.employmentType === '사외') { + return
중복 고객 통합은 관리자·대표 계정에서 사용할 수 있습니다.
+ } + + return ( +
+ + ← 고객 관리로 돌아가기 + + +
+ ) +} diff --git a/app/(mobile)/m/customers/page.tsx b/app/(mobile)/m/customers/page.tsx new file mode 100644 index 0000000..bf0783b --- /dev/null +++ b/app/(mobile)/m/customers/page.tsx @@ -0,0 +1,44 @@ +import { redirect } from 'next/navigation' +import { auth } from '@/auth' +import { prisma } from '@/lib/db' +import MobileCustomersClient from './MobileCustomersClient' + +export const dynamic = 'force-dynamic' + +export default async function MobileCustomersPage() { + const session = await auth() + if (!session?.user) redirect('/login') + + const me = session.user as { id?: string; name?: string; employmentType?: string } + const actor = me.id ? await prisma.user.findUnique({ + where: { id: me.id }, + select: { name: true, role: true, employmentType: true }, + }) : null + if (!actor) redirect('/login') + const isExternal = actor.employmentType === '사외' + const canMergeCustomers = !!actor + && ['admin', 'ceo'].includes(actor.role) + && actor.employmentType !== '사외' + + const customers = await prisma.customer.findMany({ + where: isExternal ? { assignee: actor.name } : undefined, + orderBy: { createdAt: 'desc' }, + select: { + id: true, + name: true, + phone: true, + status: true, + customerSegment: true, + assignee: true, + leads: { select: { id: true } }, + }, + }) + + return ( + + ) +} diff --git a/app/(mobile)/m/layout.tsx b/app/(mobile)/m/layout.tsx index 6189866..ff1da91 100644 --- a/app/(mobile)/m/layout.tsx +++ b/app/(mobile)/m/layout.tsx @@ -2,11 +2,12 @@ import Link from 'next/link' import { usePathname } from 'next/navigation' -import { Home, Filter, PlusCircle, Car } from 'lucide-react' +import { Home, Filter, PlusCircle, Car, Users } from 'lucide-react' const NAV = [ { href: '/m/', icon: Home, label: '홈' }, { href: '/m/pipeline', icon: Filter, label: '파이프라인' }, + { href: '/m/customers', icon: Users, label: '고객관리' }, { href: '/m/activity', icon: PlusCircle, label: '활동추가' }, { href: '/m/vehicle', icon: Car, label: '차량신청' }, ] @@ -33,7 +34,7 @@ export default function MobileLayout({ children }: { children: React.ReactNode } window.location.href = '/funnel' }} style={{ fontSize: 10, color: 'rgba(255,255,255,0.35)', letterSpacing: '0.03em' }} - className="active:opacity-60" + className="min-h-11 px-2 active:opacity-60" > PC 버전 @@ -45,7 +46,7 @@ export default function MobileLayout({ children }: { children: React.ReactNode } {/* 하단 네비게이션 */} -