회사 관리 - 등록 페이지 수정

This commit is contained in:
dohyeons
2025-11-03 14:31:21 +09:00
parent d536fd01da
commit fd7fc754f4
7 changed files with 410 additions and 16 deletions

View File

@@ -8,6 +8,7 @@ import config from "../config/environment";
import { AdminService } from "../services/adminService";
import { EncryptUtil } from "../utils/encryptUtil";
import { FileSystemManager } from "../utils/fileSystemManager";
import { validateBusinessNumber } from "../utils/businessNumberValidator";
/**
* 관리자 메뉴 목록 조회
@@ -609,9 +610,15 @@ export const getCompanyList = async (
// Raw Query로 회사 목록 조회
const companies = await query<any>(
`SELECT
company_code,
` SELECT
company_code,
company_name,
business_registration_number,
representative_name,
representative_phone,
email,
website,
address,
status,
writer,
regdate
@@ -1659,9 +1666,15 @@ export async function getCompanyListFromDB(
// Raw Query로 회사 목록 조회
const companies = await query<any>(
`SELECT
company_code,
` SELECT
company_code,
company_name,
business_registration_number,
representative_name,
representative_phone,
email,
website,
address,
writer,
regdate,
status
@@ -2440,6 +2453,25 @@ export const createCompany = async (
[company_name.trim()]
);
// 사업자등록번호 유효성 검증
const businessNumberValidation = validateBusinessNumber(
req.body.business_registration_number?.trim() || ""
);
if (!businessNumberValidation.isValid) {
res.status(400).json({
success: false,
message: businessNumberValidation.message,
errorCode: "INVALID_BUSINESS_NUMBER",
});
return;
}
// Raw Query로 사업자등록번호 중복 체크
const existingBusinessNumber = await queryOne<any>(
`SELECT company_code FROM company_mng WHERE business_registration_number = $1`,
[req.body.business_registration_number?.trim()]
);
if (existingCompany) {
res.status(400).json({
success: false,
@@ -2449,6 +2481,15 @@ export const createCompany = async (
return;
}
if (existingBusinessNumber) {
res.status(400).json({
success: false,
message: "이미 등록된 사업자등록번호입니다.",
errorCode: "DUPLICATE_BUSINESS_NUMBER",
});
return;
}
// PostgreSQL 클라이언트 생성 (복잡한 코드 생성 쿼리용)
const client = new Client({
connectionString:
@@ -2474,11 +2515,17 @@ export const createCompany = async (
const insertQuery = `
INSERT INTO company_mng (
company_code,
company_name,
company_name,
business_registration_number,
representative_name,
representative_phone,
email,
website,
address,
writer,
regdate,
status
) VALUES ($1, $2, $3, $4, $5)
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING *
`;
@@ -2488,6 +2535,12 @@ export const createCompany = async (
const insertValues = [
companyCode,
company_name.trim(),
req.body.business_registration_number?.trim() || null,
req.body.representative_name?.trim() || null,
req.body.representative_phone?.trim() || null,
req.body.email?.trim() || null,
req.body.website?.trim() || null,
req.body.address?.trim() || null,
writer,
new Date(),
"active",
@@ -2552,7 +2605,16 @@ export const updateCompany = async (
): Promise<void> => {
try {
const { companyCode } = req.params;
const { company_name, status } = req.body;
const {
company_name,
business_registration_number,
representative_name,
representative_phone,
email,
website,
address,
status,
} = req.body;
logger.info("회사 정보 수정 요청", {
companyCode,
@@ -2586,13 +2648,61 @@ export const updateCompany = async (
return;
}
// 사업자등록번호 중복 체크 및 유효성 검증 (자기 자신 제외)
if (business_registration_number && business_registration_number.trim()) {
// 유효성 검증
const businessNumberValidation = validateBusinessNumber(business_registration_number.trim());
if (!businessNumberValidation.isValid) {
res.status(400).json({
success: false,
message: businessNumberValidation.message,
errorCode: "INVALID_BUSINESS_NUMBER",
});
return;
}
// 중복 체크
const duplicateBusinessNumber = await queryOne<any>(
`SELECT company_code FROM company_mng
WHERE business_registration_number = $1 AND company_code != $2`,
[business_registration_number.trim(), companyCode]
);
if (duplicateBusinessNumber) {
res.status(400).json({
success: false,
message: "이미 등록된 사업자등록번호입니다.",
errorCode: "DUPLICATE_BUSINESS_NUMBER",
});
return;
}
}
// Raw Query로 회사 정보 수정
const result = await query<any>(
`UPDATE company_mng
SET company_name = $1, status = $2
WHERE company_code = $3
SET
company_name = $1,
business_registration_number = $2,
representative_name = $3,
representative_phone = $4,
email = $5,
website = $6,
address = $7,
status = $8
WHERE company_code = $9
RETURNING *`,
[company_name.trim(), status || "active", companyCode]
[
company_name.trim(),
business_registration_number?.trim() || null,
representative_name?.trim() || null,
representative_phone?.trim() || null,
email?.trim() || null,
website?.trim() || null,
address?.trim() || null,
status || "active",
companyCode,
]
);
if (result.length === 0) {

View File

@@ -0,0 +1,52 @@
/**
* 사업자등록번호 유효성 검사 유틸리티 (백엔드)
*/
/**
* 사업자등록번호 포맷 검증
*/
export function validateBusinessNumberFormat(value: string): boolean {
if (!value || value.trim() === "") {
return false;
}
// 하이픈 제거
const cleaned = value.replace(/-/g, "");
// 숫자 10자리인지 확인
if (!/^\d{10}$/.test(cleaned)) {
return false;
}
return true;
}
/**
* 사업자등록번호 종합 검증 (포맷만 검사)
* 실제 국세청 검증은 API 호출로 처리하는 것을 권장
*/
export function validateBusinessNumber(value: string): {
isValid: boolean;
message: string;
} {
if (!value || value.trim() === "") {
return {
isValid: false,
message: "사업자등록번호를 입력해주세요.",
};
}
if (!validateBusinessNumberFormat(value)) {
return {
isValid: false,
message: "사업자등록번호는 10자리 숫자여야 합니다.",
};
}
// 포맷만 검증하고 통과
return {
isValid: true,
message: "",
};
}

View File

@@ -5,6 +5,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
import { validateBusinessNumber, formatBusinessNumber } from "@/lib/validation/businessNumber";
interface CompanyFormModalProps {
modalState: CompanyModalState;
@@ -29,6 +30,7 @@ export function CompanyFormModal({
onClearError,
}: CompanyFormModalProps) {
const [isSaving, setIsSaving] = useState(false);
const [businessNumberError, setBusinessNumberError] = useState<string>("");
// 모달이 열려있지 않으면 렌더링하지 않음
if (!modalState.isOpen) return null;
@@ -36,15 +38,43 @@ export function CompanyFormModal({
const { mode, formData, selectedCompany } = modalState;
const isEditMode = mode === "edit";
// 사업자등록번호 변경 처리
const handleBusinessNumberChange = (value: string) => {
// 자동 포맷팅
const formatted = formatBusinessNumber(value);
onFormChange("business_registration_number", formatted);
// 유효성 검사 (10자리가 다 입력되었을 때만)
const cleaned = formatted.replace(/-/g, "");
if (cleaned.length === 10) {
const validation = validateBusinessNumber(formatted);
setBusinessNumberError(validation.isValid ? "" : validation.message);
} else if (cleaned.length < 10 && businessNumberError) {
// 10자리 미만이면 에러 초기화
setBusinessNumberError("");
}
};
// 저장 처리
const handleSave = async () => {
// 입력값 검증
// 입력값 검증 (필수 필드)
if (!formData.company_name.trim()) {
return;
}
if (!formData.business_registration_number.trim()) {
return;
}
// 사업자등록번호 최종 검증
const validation = validateBusinessNumber(formData.business_registration_number);
if (!validation.isValid) {
setBusinessNumberError(validation.message);
return;
}
setIsSaving(true);
onClearError();
setBusinessNumberError("");
try {
const success = await onSave();
@@ -81,7 +111,7 @@ export function CompanyFormModal({
</DialogHeader>
<div className="space-y-4 py-4">
{/* 회사명 입력 */}
{/* 회사명 입력 (필수) */}
<div className="space-y-2">
<Label htmlFor="company_name">
<span className="text-destructive">*</span>
@@ -97,10 +127,94 @@ export function CompanyFormModal({
/>
</div>
{/* 사업자등록번호 입력 (필수) */}
<div className="space-y-2">
<Label htmlFor="business_registration_number">
<span className="text-destructive">*</span>
</Label>
<Input
id="business_registration_number"
value={formData.business_registration_number || ""}
onChange={(e) => handleBusinessNumberChange(e.target.value)}
placeholder="000-00-00000"
disabled={isLoading || isSaving}
maxLength={12}
className={businessNumberError ? "border-destructive" : ""}
/>
{businessNumberError ? (
<p className="text-xs text-destructive">{businessNumberError}</p>
) : (
<p className="text-xs text-muted-foreground">10 ( )</p>
)}
</div>
{/* 대표자명 입력 */}
<div className="space-y-2">
<Label htmlFor="representative_name"></Label>
<Input
id="representative_name"
value={formData.representative_name || ""}
onChange={(e) => onFormChange("representative_name", e.target.value)}
placeholder="대표자명을 입력하세요"
disabled={isLoading || isSaving}
/>
</div>
{/* 대표 연락처 입력 */}
<div className="space-y-2">
<Label htmlFor="representative_phone"> </Label>
<Input
id="representative_phone"
value={formData.representative_phone || ""}
onChange={(e) => onFormChange("representative_phone", e.target.value)}
placeholder="010-0000-0000"
disabled={isLoading || isSaving}
type="tel"
/>
</div>
{/* 이메일 입력 */}
<div className="space-y-2">
<Label htmlFor="email"></Label>
<Input
id="email"
value={formData.email || ""}
onChange={(e) => onFormChange("email", e.target.value)}
placeholder="company@example.com"
disabled={isLoading || isSaving}
type="email"
/>
</div>
{/* 웹사이트 입력 */}
<div className="space-y-2">
<Label htmlFor="website"></Label>
<Input
id="website"
value={formData.website || ""}
onChange={(e) => onFormChange("website", e.target.value)}
placeholder="https://example.com"
disabled={isLoading || isSaving}
type="url"
/>
</div>
{/* 회사 주소 입력 */}
<div className="space-y-2">
<Label htmlFor="address"> </Label>
<Input
id="address"
value={formData.address || ""}
onChange={(e) => onFormChange("address", e.target.value)}
placeholder="서울특별시 강남구..."
disabled={isLoading || isSaving}
/>
</div>
{/* 에러 메시지 */}
{error && (
<div className="bg-destructive/10 rounded-md p-3">
<p className="text-destructive text-sm">{error}</p>
<div className="rounded-md bg-destructive/10 p-3">
<p className="text-sm text-destructive">{error}</p>
</div>
)}
@@ -129,7 +243,13 @@ export function CompanyFormModal({
</Button>
<Button
onClick={handleSave}
disabled={isLoading || isSaving || !formData.company_name.trim()}
disabled={
isLoading ||
isSaving ||
!formData.company_name.trim() ||
!formData.business_registration_number.trim() ||
!!businessNumberError
}
className="min-w-[80px]"
>
{(isLoading || isSaving) && <LoadingSpinner className="mr-2 h-4 w-4" />}

View File

@@ -74,6 +74,12 @@ export const MOCK_COMPANIES: Company[] = [
// 새 회사 등록 시 기본값
export const DEFAULT_COMPANY_FORM_DATA = {
company_name: "",
business_registration_number: "",
representative_name: "",
representative_phone: "",
email: "",
website: "",
address: "",
};
// 페이징 관련 상수

View File

@@ -144,6 +144,12 @@ export const useCompanyManagement = () => {
selectedCompany: company,
formData: {
company_name: company.company_name,
business_registration_number: company.business_registration_number || "",
representative_name: company.representative_name || "",
representative_phone: company.representative_phone || "",
email: company.email || "",
website: company.website || "",
address: company.address || "",
},
});
}, []);
@@ -175,6 +181,10 @@ export const useCompanyManagement = () => {
setError("회사명을 입력해주세요.");
return false;
}
if (!modalState.formData.business_registration_number.trim()) {
setError("사업자등록번호를 입력해주세요.");
return false;
}
setIsLoading(true);
setError(null);
@@ -199,6 +209,10 @@ export const useCompanyManagement = () => {
setError("올바른 데이터를 입력해주세요.");
return false;
}
if (!modalState.formData.business_registration_number.trim()) {
setError("사업자등록번호를 입력해주세요.");
return false;
}
setIsLoading(true);
setError(null);
@@ -206,6 +220,12 @@ export const useCompanyManagement = () => {
try {
await companyAPI.update(modalState.selectedCompany.company_code, {
company_name: modalState.formData.company_name,
business_registration_number: modalState.formData.business_registration_number,
representative_name: modalState.formData.representative_name,
representative_phone: modalState.formData.representative_phone,
email: modalState.formData.email,
website: modalState.formData.website,
address: modalState.formData.address,
status: modalState.selectedCompany.status,
});
closeModal();

View File

@@ -0,0 +1,74 @@
/**
* 사업자등록번호 유효성 검사 유틸리티
*/
/**
* 사업자등록번호 포맷 검증 (000-00-00000 형식)
*/
export function validateBusinessNumberFormat(value: string): boolean {
if (!value || value.trim() === "") {
return false;
}
// 하이픈 제거
const cleaned = value.replace(/-/g, "");
// 숫자 10자리인지 확인
if (!/^\d{10}$/.test(cleaned)) {
return false;
}
return true;
}
/**
* 사업자등록번호 종합 검증 (포맷만 검사)
* 실제 국세청 검증은 백엔드에서 API 호출로 처리하는 것을 권장
*/
export function validateBusinessNumber(value: string): {
isValid: boolean;
message: string;
} {
if (!value || value.trim() === "") {
return {
isValid: false,
message: "사업자등록번호를 입력해주세요.",
};
}
if (!validateBusinessNumberFormat(value)) {
return {
isValid: false,
message: "사업자등록번호는 10자리 숫자여야 합니다.",
};
}
// 포맷만 검증하고 통과
return {
isValid: true,
message: "",
};
}
/**
* 사업자등록번호 포맷팅 (자동 하이픈 추가)
*/
export function formatBusinessNumber(value: string): string {
if (!value) return "";
// 숫자만 추출
const cleaned = value.replace(/\D/g, "");
// 최대 10자리까지만
const limited = cleaned.slice(0, 10);
// 하이픈 추가 (000-00-00000)
if (limited.length <= 3) {
return limited;
} else if (limited.length <= 5) {
return `${limited.slice(0, 3)}-${limited.slice(3)}`;
} else {
return `${limited.slice(0, 3)}-${limited.slice(3, 5)}-${limited.slice(5)}`;
}
}

View File

@@ -6,6 +6,12 @@
export interface Company {
company_code: string; // 회사 코드 (varchar 32) - PK
company_name: string; // 회사명 (varchar 64)
business_registration_number?: string; // 사업자등록번호 (varchar 20)
representative_name?: string; // 대표자명 (varchar 100)
representative_phone?: string; // 대표 연락처 (varchar 20)
email?: string; // 이메일 (varchar 255)
website?: string; // 웹사이트 (varchar 500)
address?: string; // 회사 주소 (text)
writer: string; // 등록자 (varchar 32)
regdate: string; // 등록일시 (timestamp -> ISO string)
status: string; // 상태 (varchar 32)
@@ -20,7 +26,13 @@ export interface Company {
// 회사 등록/수정 폼 데이터
export interface CompanyFormData {
company_name: string; // 등록 시에는 회사명만 필요
company_name: string; // 회사명 (필수)
business_registration_number: string; // 사업자등록번호 (필수)
representative_name?: string; // 대표자명
representative_phone?: string; // 대표 연락처
email?: string; // 이메일
website?: string; // 웹사이트
address?: string; // 회사 주소
}
// 회사 검색 필터