diff --git a/backend-spring/src/main/java/com/erp/provisioning/ProvisioningController.java b/backend-spring/src/main/java/com/erp/provisioning/ProvisioningController.java index 9d3e11cc..abc21851 100644 --- a/backend-spring/src/main/java/com/erp/provisioning/ProvisioningController.java +++ b/backend-spring/src/main/java/com/erp/provisioning/ProvisioningController.java @@ -50,7 +50,8 @@ public class ProvisioningController { private static final Set RESERVED_SUBDOMAINS = Set.of( "www", "admin", "api", "app", "static", "assets", - "main", "mail", "blog", "dev", "test", "staging", "prod", "console" + "main", "mail", "blog", "dev", "test", "staging", "prod", "console", + "solution" ); @GetMapping("/table-groups") diff --git a/docker/deploy/backend-spring.Dockerfile b/docker/deploy/backend-spring.Dockerfile index fa89e83e..427ecafe 100644 --- a/docker/deploy/backend-spring.Dockerfile +++ b/docker/deploy/backend-spring.Dockerfile @@ -16,7 +16,9 @@ RUN ./gradlew bootJar --no-daemon FROM eclipse-temurin:21-jre-alpine AS runner WORKDIR /app -RUN apk add --no-cache curl +# postgresql16-client: 회사 프로비저닝 시 pg_dump 로 스키마 복사. +# 서버 PG 16.x 와 버전 맞춤 (alpine 기본 postgresql-client 는 18 이라 불일치). +RUN apk add --no-cache curl postgresql16-client COPY --from=build /app/build/libs/*.jar app.jar diff --git a/frontend/app/(main)/admin/sysMng/subdomainList/page.tsx b/frontend/app/(main)/admin/sysMng/subdomainList/page.tsx index 766ee499..5cad3844 100644 --- a/frontend/app/(main)/admin/sysMng/subdomainList/page.tsx +++ b/frontend/app/(main)/admin/sysMng/subdomainList/page.tsx @@ -81,17 +81,75 @@ export default function SubdomainListPage() { flexDirection: "column", background: "var(--v5-bg)", color: "var(--v5-text)", - fontFamily: "var(--v5-font-sans)", overflow: "hidden", }} > {/* 페이지 헤더 */}
@@ -123,8 +180,8 @@ export default function SubdomainListPage() { refetch()} icon={}> 새로고침 - }>감사 로그 - }>CSV 내보내기 + } soon>감사 로그 + } soon>CSV 내보내기 } @@ -136,10 +193,13 @@ export default function SubdomainListPage() {
{/* stats strip */} - +
+ +
{/* toolbar */}
setQ(e.target.value)} placeholder="회사명 · subdomain · company_code 검색" + className="prov-input" style={{ width: "100%", padding: "0.4rem 0.45rem 0.4rem 1.7rem", @@ -167,6 +228,7 @@ export default function SubdomainListPage() { fontSize: "0.72rem", fontFamily: "var(--v5-font-mono)", outline: "none", + transition: "all 0.18s ease", }} /> 회사 / 서브도메인 + 담당자 + 플랜 + 생성일 사용자 DB 용량 상태 @@ -239,6 +305,7 @@ export default function SubdomainListPage() { {/* rows (scrollable when long) */}
void; + soon?: boolean; }) { const isPrimary = variant === "primary"; return ( ); } @@ -389,6 +477,7 @@ function Pagination({ key={key} onClick={() => !disabled && onChange(p)} disabled={disabled} + className={`prov-pagebtn ${active ? "prov-pagebtn-active" : ""}`} style={{ minWidth: 24, height: 24, diff --git a/frontend/components/admin/provisioning/CompanyAccordionRow.tsx b/frontend/components/admin/provisioning/CompanyAccordionRow.tsx index 8396d9a5..28fc63a4 100644 --- a/frontend/components/admin/provisioning/CompanyAccordionRow.tsx +++ b/frontend/components/admin/provisioning/CompanyAccordionRow.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useLayoutEffect, useRef, useState } from "react"; import { ChevronRight, ChevronDown, @@ -19,6 +19,13 @@ import { } from "lucide-react"; import StatusDot from "./StatusDot"; +const TABS = [ + { key: "overview", label: "개요", Icon: Info, soon: false }, + { key: "members", label: "구성원", Icon: Users, soon: true }, + { key: "templates", label: "템플릿", Icon: Layers, soon: true }, + { key: "danger", label: "위험 영역", Icon: AlertTriangle, soon: false }, +] as const; + /** * 단일 회사 row. 클릭 시 accordion 펼쳐지며 탭 4개 (개요/구성원/템플릿/위험영역) 표시. * 목업 v9 AccRow 포팅. API 데이터 스키마는 /companies-stats 응답. @@ -33,6 +40,20 @@ export default function CompanyAccordionRow({ onToggle: () => void; }) { const [tab, setTab] = useState<"overview" | "members" | "templates" | "danger">("overview"); + const tabsWrapRef = useRef(null); + const tabBtnRefs = useRef>({}); + const [indicator, setIndicator] = useState<{ left: number; width: number }>({ left: 0, width: 0 }); + + useLayoutEffect(() => { + if (!open) return; + const el = tabBtnRefs.current[tab]; + const wrap = tabsWrapRef.current; + if (el && wrap) { + const r1 = el.getBoundingClientRect(); + const r2 = wrap.getBoundingClientRect(); + setIndicator({ left: r1.left - r2.left, width: r1.width }); + } + }, [tab, open]); const sub = r.subdomain || ""; const name = r.company_name || r.name || r.company_code; @@ -45,25 +66,26 @@ export default function CompanyAccordionRow({ return (
- {open && ( -
- )} +
- {open && ( -
+
+
+
{/* tabs */} -
- {([ - ["overview", "개요", Info], - ["members", "구성원", Users], - ["templates", "템플릿", Layers], - ["danger", "위험 영역", AlertTriangle], - ] as const).map(([k, l, IconC]) => ( +
+ {TABS.map(({ key: k, label: l, Icon: IconC, soon }) => ( ))}
@@ -219,6 +347,21 @@ export default function CompanyAccordionRow({ {r.created && <>생성 {formatDate(r.created)}} {r.writer && <> · writer {r.writer}}
+ + {/* sliding indicator */} +
{tab === "overview" && ( @@ -313,10 +456,10 @@ export default function CompanyAccordionRow({ if (sub) window.open(`http://${sub}.invyone.com`, "_blank"); }} > - 테넌트 접속 + 회사 사이트 열기 - }>관리자 계정 - }>템플릿 재복제 + } soon>관리자 계정 + } soon>템플릿 재복제
@@ -407,7 +550,8 @@ export default function CompanyAccordionRow({
)}
- )} +
+
); } @@ -445,17 +589,22 @@ function ABtn({ children, icon, onClick, + soon, }: { children: React.ReactNode; icon?: React.ReactNode; onClick?: (e: React.MouseEvent) => void; + soon?: boolean; }) { return ( ); } @@ -512,3 +677,71 @@ function formatDate(v: any): string { return String(v); } } + +function formatRelative(v: any): string { + if (!v) return ""; + try { + const d = typeof v === "number" ? new Date(v) : new Date(String(v)); + if (isNaN(d.getTime())) return ""; + const diffMs = Date.now() - d.getTime(); + const sec = Math.round(diffMs / 1000); + if (sec < 60) return "방금"; + const min = Math.round(sec / 60); + if (min < 60) return `${min}분 전`; + const hr = Math.round(min / 60); + if (hr < 24) return `${hr}시간 전`; + const day = Math.round(hr / 24); + if (day < 7) return `${day}일 전`; + const wk = Math.round(day / 7); + if (wk < 5) return `${wk}주 전`; + const mo = Math.round(day / 30); + if (mo < 12) return `${mo}개월 전`; + const yr = Math.round(day / 365); + return `${yr}년 전`; + } catch { + return ""; + } +} + +const PLAN_STYLE: Record = { + ENTERPRISE: { + bg: "rgba(var(--v5-primary-rgb), 0.12)", + color: "var(--v5-primary)", + border: "rgba(var(--v5-primary-rgb), 0.3)", + }, + STANDARD: { + bg: "rgba(var(--v5-cyan-rgb), 0.1)", + color: "var(--v5-cyan)", + border: "rgba(var(--v5-cyan-rgb), 0.3)", + }, + STARTER: { + bg: "var(--v5-bg-subtle)", + color: "var(--v5-text-sec)", + border: "var(--v5-border)", + }, +}; + +function PlanBadge({ plan }: { plan: string }) { + const key = plan.toUpperCase(); + const s = PLAN_STYLE[key] || PLAN_STYLE.STARTER; + return ( + + {key} + + ); +} diff --git a/frontend/components/admin/provisioning/StatusDot.tsx b/frontend/components/admin/provisioning/StatusDot.tsx index b867198e..23f069b2 100644 --- a/frontend/components/admin/provisioning/StatusDot.tsx +++ b/frontend/components/admin/provisioning/StatusDot.tsx @@ -32,7 +32,7 @@ export default function StatusDot({ status }: { status?: string }) { height: 6, borderRadius: "50%", background: m.color, - boxShadow: `0 0 6px ${m.color}`, + boxShadow: `0 0 3px ${m.color}`, animation: isProvisioning ? "pulsedot 1.4s ease-in-out infinite" : "none", }} /> diff --git a/frontend/components/admin/provisioning/wizard/Step1Basic.tsx b/frontend/components/admin/provisioning/wizard/Step1Basic.tsx index 7f66a180..8bd84fc1 100644 --- a/frontend/components/admin/provisioning/wizard/Step1Basic.tsx +++ b/frontend/components/admin/provisioning/wizard/Step1Basic.tsx @@ -105,43 +105,45 @@ export default function Step1Basic({ return (
{/* 왼쪽: 폼 */}
- 01 · BASIC + 01 · 기본 정보
-

+

회사 기본 정보

-
+
subdomain 을 입력하면 접속 URL 과 DB 이름이 자동 결정됩니다. 생성 후에는 변경 불가합니다.
{/* ── 식별자 ── */} -
+
} + icon={} label="식별자" hint="생성 후 변경 불가" /> -
+
@@ -187,21 +189,21 @@ export default function Step1Basic({ setState({ company_name: v })} - placeholder="큐엔씨 주식회사" + placeholder="화면·문서에 표시될 회사 이름" />
{/* ── 법인 정보 ── */} -
- } label="법인 정보" /> -
+
+ } label="법인 정보" /> +
setState({ business_registration_number: v })} - placeholder="123-45-67890" + placeholder="xxx-xx-xxxxx" mono /> @@ -209,14 +211,14 @@ export default function Step1Basic({ setState({ representative_name: v })} - placeholder="홍길동" + placeholder="대표자 성함" /> setState({ representative_phone: v })} - placeholder="02-1234-5678" + placeholder="02-0000-0000" mono /> @@ -224,7 +226,7 @@ export default function Step1Basic({ setState({ email: v })} - placeholder="admin@company.kr" + placeholder="admin@example.com" mono /> @@ -232,14 +234,14 @@ export default function Step1Basic({ setState({ address: v })} - placeholder="서울특별시 강남구 테헤란로 123" + placeholder="도로명 주소" /> setState({ website: v })} - placeholder="https://company.kr" + placeholder="https://" mono /> @@ -251,25 +253,25 @@ export default function Step1Basic({
- LIVE PREVIEW +실시간 미리보기
@@ -283,7 +285,7 @@ export default function Step1Basic({ - + {state.db_prefix || "___"} @@ -300,20 +302,21 @@ export default function Step1Basic({
- +
주의 — subdomain 과 db_prefix 는 생성 후 변경할 수 없습니다.
@@ -336,15 +339,15 @@ function SectionHead({ return (
@@ -357,6 +360,7 @@ function SectionHead({ fontWeight: 400, textTransform: "none", letterSpacing: 0, + fontSize: "0.72rem", }} > — {hint} @@ -368,30 +372,32 @@ function SectionHead({ function PreviewField({ label, children }: { label: string; children: React.ReactNode }) { return ( -
+
{label}
{children} diff --git a/frontend/components/admin/provisioning/wizard/Step2Template.tsx b/frontend/components/admin/provisioning/wizard/Step2Template.tsx index bceac97e..317a2236 100644 --- a/frontend/components/admin/provisioning/wizard/Step2Template.tsx +++ b/frontend/components/admin/provisioning/wizard/Step2Template.tsx @@ -70,40 +70,42 @@ export default function Step2Template({ return (
- 02 · TEMPLATE + 02 · 템플릿 선택
-

+

복사할 기준 데이터

-
+
기본 회사(INVION_DEFAULT) 의 기준 데이터 중 새 회사에 복제할 항목을 선택합니다. 필수 그룹은 해제할 수 없습니다.
{isLoading && ( -
로딩 중...
+
로딩 중...
)} -
+
{required.length > 0 && ( <> 필수 (해제 불가) @@ -141,24 +143,25 @@ export default function Step2Template({
- SUMMARY +요약
!locked && onToggle()} style={{ - padding: "0.85rem 1rem", + padding: "1rem 1.15rem", display: "flex", alignItems: "center", - gap: "0.7rem", + gap: "0.85rem", cursor: locked ? "not-allowed" : "pointer", }} > {/* checkbox */}
- {checked && } + {checked && }
{/* icon tile */}
- +
{/* label */}
-
- +
+ {g.label || g.id} {locked && ( - 필수 + 필수 )}
{meta.join(" · ")} @@ -311,53 +318,66 @@ function TemplateCard({ onExpand(); }} aria-label={expanded ? "접기" : "펼치기"} + className="wiz-chevron" style={{ - width: 24, - height: 24, + width: 30, + height: 30, border: "1px solid var(--v5-border)", - borderRadius: 6, + borderRadius: 7, background: "transparent", color: "var(--v5-text-muted)", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center", + transition: "all 0.2s ease", }} > - {expanded ? : } + {expanded ? : } )}
- {expanded && tables.length > 0 && ( -
- {tables.map((t) => ( - 0 ? "1fr" : "0fr", + transition: "grid-template-rows 0.3s cubic-bezier(0.4,0,0.2,1)", + }} + > +
+ {tables.length > 0 && ( +
- {t} - - ))} + {tables.map((t) => ( + + {t} + + ))} +
+ )}
- )} +
); } @@ -366,12 +386,12 @@ function SubHead({ children, style }: { children: React.ReactNode; style?: React return (
- {label} + {label} +
setVisible((v) => !v)}> - {visible ? : } + {visible ? : } - +
); return ( -
+
- 03 · ADMIN ACCOUNT + 03 · 관리자 계정
-

+

초기 관리자 계정

-
+
시스템이 자동으로{" "} - COMPANY_ADMIN 권한의 관리자 계정을 + COMPANY_ADMIN 권한의 관리자 계정을 생성합니다. 생성된 비밀번호는{" "} 이 화면을 벗어나면 다시 볼 수 없으며, DB 에는 BCrypt 해시만 저장됩니다.
} + trailing={} > {userId} @@ -149,21 +157,21 @@ export default function Step3Admin({
- + {visible ? pw : "•".repeat(pw.length || 12)}
@@ -171,30 +179,30 @@ export default function Step3Admin({ - + COMPANY_ADMIN -