Merge remote-tracking branch 'origin/main' into hjjeong
This commit is contained in:
@@ -295,7 +295,8 @@ public class AdminController {
|
|||||||
@PostMapping("/users/reset-password")
|
@PostMapping("/users/reset-password")
|
||||||
public ResponseEntity<ApiResponse<Void>> resetUserPassword(@RequestBody Map<String, Object> body) {
|
public ResponseEntity<ApiResponse<Void>> resetUserPassword(@RequestBody Map<String, Object> body) {
|
||||||
String userId = (String) body.get("user_id");
|
String userId = (String) body.get("user_id");
|
||||||
adminService.resetUserPassword(userId);
|
String newPassword = (String) body.get("new_password");
|
||||||
|
adminService.resetUserPassword(userId, newPassword);
|
||||||
return ResponseEntity.ok(ApiResponse.success(null, "비밀번호 초기화 성공"));
|
return ResponseEntity.ok(ApiResponse.success(null, "비밀번호 초기화 성공"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ public class SubstituteController {
|
|||||||
@PathVariable("id") Long substituteId,
|
@PathVariable("id") Long substituteId,
|
||||||
@RequestAttribute("company_code") String companyCode,
|
@RequestAttribute("company_code") String companyCode,
|
||||||
@RequestAttribute("role") String role) {
|
@RequestAttribute("role") String role) {
|
||||||
if (!"ADMIN".equals(role) && !"SUPER_ADMIN".equals(role)) {
|
if (!"ADMIN".equals(role) && !"COMPANY_ADMIN".equals(role) && !"SUPER_ADMIN".equals(role)) {
|
||||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||||
.body(ApiResponse.error("관리자만 조회할 수 있습니다."));
|
.body(ApiResponse.error("관리자만 조회할 수 있습니다."));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -208,10 +208,17 @@ public class AdminService extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void resetUserPassword(String userId) {
|
public void resetUserPassword(String userId) {
|
||||||
String defaultPw = passwordEncoder.encode("Welcome1!");
|
resetUserPassword(userId, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void resetUserPassword(String userId, String newPassword) {
|
||||||
|
if (userId == null || userId.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("user_id 는 필수입니다");
|
||||||
|
}
|
||||||
|
String rawPw = (newPassword != null && !newPassword.isBlank()) ? newPassword : "Welcome1!";
|
||||||
Map<String, Object> params = new HashMap<>();
|
Map<String, Object> params = new HashMap<>();
|
||||||
params.put("user_id", userId);
|
params.put("user_id", userId);
|
||||||
params.put("user_password", defaultPw);
|
params.put("user_password", passwordEncoder.encode(rawPw));
|
||||||
sqlSession.update("admin.updateUserPassword", params);
|
sqlSession.update("admin.updateUserPassword", params);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,12 @@ public class DdlService extends BaseService {
|
|||||||
"id", "created_date", "updated_date", "company_code"
|
"id", "created_date", "updated_date", "company_code"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** 사용자가 신규 추가하는 컬럼에 허용되는 INPUT_TYPE 8종 (백엔드 백스톱) */
|
||||||
|
private static final Set<String> USER_SELECTABLE_INPUT_TYPES = Set.of(
|
||||||
|
"text", "number", "date", "code", "entity",
|
||||||
|
"numbering", "file", "image"
|
||||||
|
);
|
||||||
|
|
||||||
public DdlService(JdbcTemplate jdbcTemplate, PlatformTransactionManager transactionManager) {
|
public DdlService(JdbcTemplate jdbcTemplate, PlatformTransactionManager transactionManager) {
|
||||||
this.jdbcTemplate = jdbcTemplate;
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
this.transactionTemplate = new TransactionTemplate(transactionManager);
|
this.transactionTemplate = new TransactionTemplate(transactionManager);
|
||||||
@@ -140,6 +146,12 @@ public class DdlService extends BaseService {
|
|||||||
transactionTemplate.execute(status -> {
|
transactionTemplate.execute(status -> {
|
||||||
jdbcTemplate.execute(ddlQuery);
|
jdbcTemplate.execute(ddlQuery);
|
||||||
String inputType = convertToInputType(column);
|
String inputType = convertToInputType(column);
|
||||||
|
if (!USER_SELECTABLE_INPUT_TYPES.contains(inputType)) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"INPUT_TYPE 은 다음 8개 중 하나여야 합니다: " + USER_SELECTABLE_INPUT_TYPES
|
||||||
|
+ " (받은 값: " + inputType + ")"
|
||||||
|
);
|
||||||
|
}
|
||||||
String detailSettings = column.containsKey("detail_settings")
|
String detailSettings = column.containsKey("detail_settings")
|
||||||
? column.get("detail_settings").toString() : "{}";
|
? column.get("detail_settings").toString() : "{}";
|
||||||
Integer maxOrder = jdbcTemplate.queryForObject(
|
Integer maxOrder = jdbcTemplate.queryForObject(
|
||||||
@@ -408,10 +420,17 @@ public class DdlService extends BaseService {
|
|||||||
// 사용자 정의 컬럼
|
// 사용자 정의 컬럼
|
||||||
for (int i = 0; i < columns.size(); i++) {
|
for (int i = 0; i < columns.size(); i++) {
|
||||||
Map<String, Object> col = columns.get(i);
|
Map<String, Object> col = columns.get(i);
|
||||||
|
String inputType = convertToInputType(col);
|
||||||
|
if (!USER_SELECTABLE_INPUT_TYPES.contains(inputType)) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"INPUT_TYPE 은 다음 8개 중 하나여야 합니다: " + USER_SELECTABLE_INPUT_TYPES
|
||||||
|
+ " (받은 값: " + inputType + ")"
|
||||||
|
);
|
||||||
|
}
|
||||||
String detailSettings = col.containsKey("detail_settings")
|
String detailSettings = col.containsKey("detail_settings")
|
||||||
? col.get("detail_settings").toString() : "{}";
|
? col.get("detail_settings").toString() : "{}";
|
||||||
saveColumnMeta(tableName, (String) col.get("name"), companyCode,
|
saveColumnMeta(tableName, (String) col.get("name"), companyCode,
|
||||||
convertToInputType(col), detailSettings, i);
|
inputType, detailSettings, i);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -239,7 +239,7 @@ public class SubstituteService extends BaseService {
|
|||||||
|
|
||||||
private void requireAdmin(Map<String, Object> params) {
|
private void requireAdmin(Map<String, Object> params) {
|
||||||
String role = (String) params.get("role");
|
String role = (String) params.get("role");
|
||||||
if (!"ADMIN".equals(role) && !"SUPER_ADMIN".equals(role)) {
|
if (!"ADMIN".equals(role) && !"COMPANY_ADMIN".equals(role) && !"SUPER_ADMIN".equals(role)) {
|
||||||
throw new AccessDeniedException("관리자만 대무자를 지정/수정/해지할 수 있습니다.");
|
throw new AccessDeniedException("관리자만 대무자를 지정/수정/해지할 수 있습니다.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,12 @@ public class TableManagementService extends BaseService {
|
|||||||
|
|
||||||
private static final String NS = "tableManagement.";
|
private static final String NS = "tableManagement.";
|
||||||
|
|
||||||
|
/** 사용자가 직접 선택 가능한 INPUT_TYPE 8종 (INSERT/UPDATE-type 검증용) */
|
||||||
|
private static final Set<String> USER_SELECTABLE_INPUT_TYPES = Set.of(
|
||||||
|
"text", "number", "date", "code", "entity",
|
||||||
|
"numbering", "file", "image"
|
||||||
|
);
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────
|
||||||
// 테이블 목록
|
// 테이블 목록
|
||||||
// ──────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────
|
||||||
@@ -145,7 +151,9 @@ public class TableManagementService extends BaseService {
|
|||||||
Map<String, Object> settings, String companyCode) {
|
Map<String, Object> settings, String companyCode) {
|
||||||
ensureTableInLabels(tableName);
|
ensureTableInLabels(tableName);
|
||||||
|
|
||||||
String inputType = normalizeInputType((String) settings.get("input_type"));
|
boolean inputTypeChanged = settings.containsKey("input_type");
|
||||||
|
String ctx = inputTypeChanged ? "user-update-type" : "user-update-other";
|
||||||
|
String inputType = normalizeInputType((String) settings.get("input_type"), ctx);
|
||||||
Map<String, Object> params = new HashMap<>();
|
Map<String, Object> params = new HashMap<>();
|
||||||
params.put("table_name", tableName);
|
params.put("table_name", tableName);
|
||||||
params.put("column_name", columnName);
|
params.put("column_name", columnName);
|
||||||
@@ -202,7 +210,7 @@ public class TableManagementService extends BaseService {
|
|||||||
public void updateColumnInputType(String tableName, String columnName,
|
public void updateColumnInputType(String tableName, String columnName,
|
||||||
String inputType, String companyCode,
|
String inputType, String companyCode,
|
||||||
Map<String, Object> detailSettings) {
|
Map<String, Object> detailSettings) {
|
||||||
String finalType = normalizeInputType(inputType);
|
String finalType = normalizeInputType(inputType, "user-update-type");
|
||||||
Map<String, Object> params = new HashMap<>();
|
Map<String, Object> params = new HashMap<>();
|
||||||
params.put("table_name", tableName);
|
params.put("table_name", tableName);
|
||||||
params.put("column_name", columnName);
|
params.put("column_name", columnName);
|
||||||
@@ -853,7 +861,7 @@ public class TableManagementService extends BaseService {
|
|||||||
return name.replaceAll("[^a-zA-Z0-9_]", "");
|
return name.replaceAll("[^a-zA-Z0-9_]", "");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** "direct" / "auto" → "text" 변환 */
|
/** "direct" / "auto" → "text" 변환 (legacy 호출처 보호 — system-normalize 동작) */
|
||||||
private String normalizeInputType(String inputType) {
|
private String normalizeInputType(String inputType) {
|
||||||
if ("direct".equals(inputType) || "auto".equals(inputType)) {
|
if ("direct".equals(inputType) || "auto".equals(inputType)) {
|
||||||
log.warn("잘못된 inputType 값 감지: {} → 'text'로 변환", inputType);
|
log.warn("잘못된 inputType 값 감지: {} → 'text'로 변환", inputType);
|
||||||
@@ -862,6 +870,24 @@ public class TableManagementService extends BaseService {
|
|||||||
return inputType != null ? inputType : "text";
|
return inputType != null ? inputType : "text";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* context 에 따라 INPUT_TYPE 정규화 및 검증.
|
||||||
|
* @param context "user-insert" | "user-update-type" | "user-update-other" | "system-normalize"
|
||||||
|
*/
|
||||||
|
private String normalizeInputType(String value, String context) {
|
||||||
|
if ("user-insert".equals(context) || "user-update-type".equals(context)) {
|
||||||
|
if (value == null || !USER_SELECTABLE_INPUT_TYPES.contains(value)) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"INPUT_TYPE 은 다음 8개 중 하나여야 합니다: " + USER_SELECTABLE_INPUT_TYPES
|
||||||
|
+ " (받은 값: " + value + ")"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
// user-update-other / system-normalize: 기존 동작 그대로
|
||||||
|
return normalizeInputType(value);
|
||||||
|
}
|
||||||
|
|
||||||
private String toJsonString(Object obj) {
|
private String toJsonString(Object obj) {
|
||||||
if (obj == null) return "{}";
|
if (obj == null) return "{}";
|
||||||
if (obj instanceof String s) return s.isBlank() ? "{}" : s;
|
if (obj instanceof String s) return s.isBlank() ? "{}" : s;
|
||||||
|
|||||||
@@ -222,7 +222,7 @@
|
|||||||
AND L.COMPANY_CODE = R.COMPANY_CODE
|
AND L.COMPANY_CODE = R.COMPANY_CODE
|
||||||
)
|
)
|
||||||
</if>
|
</if>
|
||||||
ORDER BY R.CREATED_DATE DESC
|
ORDER BY R.CREATED_AT DESC
|
||||||
<if test="page_limit != null">
|
<if test="page_limit != null">
|
||||||
LIMIT #{page_limit} OFFSET #{page_offset}
|
LIMIT #{page_limit} OFFSET #{page_offset}
|
||||||
</if>
|
</if>
|
||||||
@@ -465,7 +465,7 @@
|
|||||||
SELECT L.*,
|
SELECT L.*,
|
||||||
R.TITLE, R.TARGET_TABLE, R.TARGET_RECORD_ID,
|
R.TITLE, R.TARGET_TABLE, R.TARGET_RECORD_ID,
|
||||||
R.REQUESTER_NAME, R.REQUESTER_DEPT,
|
R.REQUESTER_NAME, R.REQUESTER_DEPT,
|
||||||
R.CREATED_DATE AS REQUEST_CREATED_DATE
|
R.CREATED_AT AS REQUEST_CREATED_DATE
|
||||||
FROM APPROVAL_LINES L
|
FROM APPROVAL_LINES L
|
||||||
JOIN APPROVAL_REQUESTS R
|
JOIN APPROVAL_REQUESTS R
|
||||||
ON L.REQUEST_ID = R.REQUEST_ID AND L.COMPANY_CODE = R.COMPANY_CODE
|
ON L.REQUEST_ID = R.REQUEST_ID AND L.COMPANY_CODE = R.COMPANY_CODE
|
||||||
@@ -475,7 +475,7 @@
|
|||||||
</foreach>
|
</foreach>
|
||||||
AND L.STATUS = 'pending'
|
AND L.STATUS = 'pending'
|
||||||
AND (L.COMPANY_CODE = #{company_code} OR L.COMPANY_CODE = '*')
|
AND (L.COMPANY_CODE = #{company_code} OR L.COMPANY_CODE = '*')
|
||||||
ORDER BY R.CREATED_DATE ASC
|
ORDER BY R.CREATED_AT ASC
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<!-- ================================================================
|
<!-- ================================================================
|
||||||
|
|||||||
@@ -246,8 +246,8 @@
|
|||||||
#{company_code}
|
#{company_code}
|
||||||
, #{original_user_id}
|
, #{original_user_id}
|
||||||
, #{proxy_user_id}
|
, #{proxy_user_id}
|
||||||
, #{start_date}
|
, CAST(#{start_date} AS DATE)
|
||||||
, #{end_date}
|
, CAST(#{end_date} AS DATE)
|
||||||
, #{reason}
|
, #{reason}
|
||||||
, COALESCE(#{is_active}, TRUE)
|
, COALESCE(#{is_active}, TRUE)
|
||||||
, #{created_by}
|
, #{created_by}
|
||||||
|
|||||||
@@ -34,9 +34,12 @@ RUN echo "Build SHA: $GIT_SHA"
|
|||||||
|
|
||||||
# Build the application
|
# Build the application
|
||||||
ENV DISABLE_ESLINT_PLUGIN=true
|
ENV DISABLE_ESLINT_PLUGIN=true
|
||||||
|
ENV NODE_OPTIONS=--max-old-space-size=4096
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Production image, copy all the files and run next
|
# Production image — Next.js standalone output 활용
|
||||||
|
# next.config.mjs 의 `output: "standalone"` 이 빌드 시 .next/standalone/ 에
|
||||||
|
# server.js + 실제로 사용되는 node_modules 만 자동 포함. node_modules 통째 COPY 불필요.
|
||||||
FROM base AS runner
|
FROM base AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -46,15 +49,13 @@ ENV NEXT_TELEMETRY_DISABLED 1
|
|||||||
RUN addgroup --system --gid 1001 nodejs
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
RUN adduser --system --uid 1001 nextjs
|
RUN adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
# Copy the Next.js build output
|
# public 폴더 (standalone 가 자동 포함하지 않음)
|
||||||
COPY --from=builder /app/public ./public
|
COPY --from=builder /app/public ./public
|
||||||
|
|
||||||
# Production 모드에서는 .next 폴더 전체를 복사
|
# standalone 빌드 결과: server.js + minimal node_modules
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/package.json ./package.json
|
# static asset (standalone 가 자동 포함하지 않음)
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
# node_modules 복사 (production dependencies)
|
|
||||||
COPY --from=deps --chown=nextjs:nodejs /app/node_modules ./node_modules
|
|
||||||
|
|
||||||
USER nextjs
|
USER nextjs
|
||||||
|
|
||||||
@@ -63,6 +64,6 @@ EXPOSE 3000
|
|||||||
ENV PORT 3000
|
ENV PORT 3000
|
||||||
ENV HOSTNAME "0.0.0.0"
|
ENV HOSTNAME "0.0.0.0"
|
||||||
|
|
||||||
# Next.js start 명령어 사용
|
# standalone 의 server.js 직접 실행 (npm start 대신)
|
||||||
CMD ["npm", "start"]
|
CMD ["node", "server.js"]
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import { showErrorToast } from "@/lib/utils/toastUtils";
|
|||||||
import { useMultiLang } from "@/hooks/useMultiLang";
|
import { useMultiLang } from "@/hooks/useMultiLang";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import { TABLE_MANAGEMENT_KEYS } from "@/constants/tableManagement";
|
import { TABLE_MANAGEMENT_KEYS } from "@/constants/tableManagement";
|
||||||
import { INPUT_TYPE_OPTIONS } from "@/types/input-types";
|
import { INPUT_TYPE_OPTIONS, USER_SELECTABLE_INPUT_TYPE_ORDER } from "@/types/input-types";
|
||||||
import { apiClient } from "@/lib/api/client";
|
import { apiClient } from "@/lib/api/client";
|
||||||
import { commonCodeApi } from "@/lib/api/commonCode";
|
import { commonCodeApi } from "@/lib/api/commonCode";
|
||||||
import { entityJoinApi, ReferenceTableColumn } from "@/lib/api/entityJoin";
|
import { entityJoinApi, ReferenceTableColumn } from "@/lib/api/entityJoin";
|
||||||
@@ -203,12 +203,14 @@ export default function TableManagementPage() {
|
|||||||
[], // 의존성 배열에서 referenceTableColumns 제거
|
[], // 의존성 배열에서 referenceTableColumns 제거
|
||||||
);
|
);
|
||||||
|
|
||||||
// 입력 타입 옵션 (8개 핵심 타입)
|
// 입력 타입 옵션 (8개 사용자 선택 가능 타입 — Layer 2)
|
||||||
const inputTypeOptions = INPUT_TYPE_OPTIONS.map((option) => ({
|
const inputTypeOptions = INPUT_TYPE_OPTIONS
|
||||||
value: option.value,
|
.filter((o) => USER_SELECTABLE_INPUT_TYPE_ORDER.includes(o.value as any))
|
||||||
label: option.label,
|
.map((option) => ({
|
||||||
description: option.description,
|
value: option.value,
|
||||||
}));
|
label: option.label,
|
||||||
|
description: option.description,
|
||||||
|
}));
|
||||||
|
|
||||||
// 메모이제이션된 입력타입 옵션
|
// 메모이제이션된 입력타입 옵션
|
||||||
const memoizedInputTypeOptions = useMemo(() => inputTypeOptions, []);
|
const memoizedInputTypeOptions = useMemo(() => inputTypeOptions, []);
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ import {
|
|||||||
RESERVED_WORDS,
|
RESERVED_WORDS,
|
||||||
RESERVED_COLUMNS,
|
RESERVED_COLUMNS,
|
||||||
} from "../../types/ddl";
|
} from "../../types/ddl";
|
||||||
import { INPUT_TYPE_OPTIONS } from "../../types/input-types";
|
import { INPUT_TYPE_OPTIONS, USER_SELECTABLE_INPUT_TYPE_ORDER } from "../../types/input-types";
|
||||||
|
|
||||||
export function AddColumnModal({ isOpen, onClose, table_name, onSuccess }: AddColumnModalProps) {
|
export function AddColumnModal({ isOpen, onClose, table_name, onSuccess }: AddColumnModalProps) {
|
||||||
const [column, setColumn] = useState<CreateColumnDefinition>({
|
const [column, setColumn] = useState<CreateColumnDefinition>({
|
||||||
@@ -247,16 +247,18 @@ export function AddColumnModal({ isOpen, onClose, table_name, onSuccess }: AddCo
|
|||||||
<SelectValue placeholder="입력타입 선택" />
|
<SelectValue placeholder="입력타입 선택" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{INPUT_TYPE_OPTIONS.map((option) => (
|
{INPUT_TYPE_OPTIONS
|
||||||
<SelectItem key={option.value} value={option.value}>
|
.filter((o) => USER_SELECTABLE_INPUT_TYPE_ORDER.includes(o.value as any))
|
||||||
<div>
|
.map((option) => (
|
||||||
<div className="font-medium">{option.label}</div>
|
<SelectItem key={option.value} value={option.value}>
|
||||||
{option.description && (
|
<div>
|
||||||
<div className="text-muted-foreground text-xs">{option.description}</div>
|
<div className="font-medium">{option.label}</div>
|
||||||
)}
|
{option.description && (
|
||||||
</div>
|
<div className="text-muted-foreground text-xs">{option.description}</div>
|
||||||
</SelectItem>
|
)}
|
||||||
))}
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import {
|
|||||||
RESERVED_WORDS,
|
RESERVED_WORDS,
|
||||||
RESERVED_COLUMNS,
|
RESERVED_COLUMNS,
|
||||||
} from "../../types/ddl";
|
} from "../../types/ddl";
|
||||||
import { INPUT_TYPE_OPTIONS } from "../../types/input-types";
|
import { INPUT_TYPE_OPTIONS, USER_SELECTABLE_INPUT_TYPE_ORDER } from "../../types/input-types";
|
||||||
|
|
||||||
export function ColumnDefinitionTable({ columns, onChange, disabled = false }: ColumnDefinitionTableProps) {
|
export function ColumnDefinitionTable({ columns, onChange, disabled = false }: ColumnDefinitionTableProps) {
|
||||||
const [validationErrors, setValidationErrors] = useState<Record<number, string[]>>({});
|
const [validationErrors, setValidationErrors] = useState<Record<number, string[]>>({});
|
||||||
@@ -228,16 +228,18 @@ export function ColumnDefinitionTable({ columns, onChange, disabled = false }: C
|
|||||||
<SelectValue placeholder="입력 타입 선택" />
|
<SelectValue placeholder="입력 타입 선택" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{INPUT_TYPE_OPTIONS.map((option) => (
|
{INPUT_TYPE_OPTIONS
|
||||||
<SelectItem key={option.value} value={option.value}>
|
.filter((o) => USER_SELECTABLE_INPUT_TYPE_ORDER.includes(o.value as any))
|
||||||
<div>
|
.map((option) => (
|
||||||
<div className="font-medium">{option.label}</div>
|
<SelectItem key={option.value} value={option.value}>
|
||||||
{option.description && (
|
<div>
|
||||||
<div className="text-muted-foreground text-xs">{option.description}</div>
|
<div className="font-medium">{option.label}</div>
|
||||||
)}
|
{option.description && (
|
||||||
</div>
|
<div className="text-muted-foreground text-xs">{option.description}</div>
|
||||||
</SelectItem>
|
)}
|
||||||
))}
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|||||||
@@ -6,12 +6,20 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import {
|
import {
|
||||||
getSubstituteList,
|
getSubstituteList,
|
||||||
createSubstitute,
|
createSubstitute,
|
||||||
deleteSubstitute,
|
deleteSubstitute,
|
||||||
checkSubstituteOverlap,
|
checkSubstituteOverlap,
|
||||||
} from "@/lib/api/substitute";
|
} from "@/lib/api/substitute";
|
||||||
|
import { getUserList } from "@/lib/api/user";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 사용자별 대무자(代務者) 관리 섹션.
|
* 사용자별 대무자(代務者) 관리 섹션.
|
||||||
@@ -39,6 +47,10 @@ export function SubstituteSection({ originalUserId, originalUserName }: Substitu
|
|||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// 같은 회사 활성 사용자 목록 (대무자 후보) — 본인/SUPER_ADMIN/비활성 제외
|
||||||
|
const [candidates, setCandidates] = useState<Record<string, any>[]>([]);
|
||||||
|
const [candidatesLoading, setCandidatesLoading] = useState(false);
|
||||||
|
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
proxy_user_id: "",
|
proxy_user_id: "",
|
||||||
start_date: "",
|
start_date: "",
|
||||||
@@ -63,10 +75,37 @@ export function SubstituteSection({ originalUserId, originalUserName }: Substitu
|
|||||||
load();
|
load();
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
|
// 대무자 후보 사용자 목록 로드 (다이얼로그 열릴 때 한번)
|
||||||
|
const loadCandidates = useCallback(async () => {
|
||||||
|
setCandidatesLoading(true);
|
||||||
|
const res: any = await getUserList({ status: "active", limit: 1000 });
|
||||||
|
// user.ts 의 getUserList 는 axios response 의 data 를 반환:
|
||||||
|
// { success, data: [...], total, ... } (data 가 list 자체)
|
||||||
|
// 또는 cross-tenant mode 등에서 { data: { list: [...] } } 일 수도 있어 둘 다 지원.
|
||||||
|
const rawList: any[] = Array.isArray(res?.data)
|
||||||
|
? res.data
|
||||||
|
: Array.isArray(res?.data?.list)
|
||||||
|
? res.data.list
|
||||||
|
: [];
|
||||||
|
if (rawList.length > 0) {
|
||||||
|
const filtered = (rawList as Record<string, any>[]).filter(
|
||||||
|
(u) =>
|
||||||
|
u.user_id !== originalUserId &&
|
||||||
|
u.user_type !== "SUPER_ADMIN" &&
|
||||||
|
u.status === "active",
|
||||||
|
);
|
||||||
|
setCandidates(filtered);
|
||||||
|
} else {
|
||||||
|
setCandidates([]);
|
||||||
|
}
|
||||||
|
setCandidatesLoading(false);
|
||||||
|
}, [originalUserId]);
|
||||||
|
|
||||||
const openDialog = () => {
|
const openDialog = () => {
|
||||||
setForm({ proxy_user_id: "", start_date: "", end_date: "", reason: "" });
|
setForm({ proxy_user_id: "", start_date: "", end_date: "", reason: "" });
|
||||||
setError(null);
|
setError(null);
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
|
loadCandidates();
|
||||||
};
|
};
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
@@ -215,16 +254,36 @@ export function SubstituteSection({ originalUserId, originalUserName }: Substitu
|
|||||||
|
|
||||||
<div className="space-y-3 py-2 text-sm">
|
<div className="space-y-3 py-2 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="proxy_user_id">대무자 사용자 ID</Label>
|
<Label htmlFor="proxy_user_id">대무자</Label>
|
||||||
<Input
|
<Select
|
||||||
id="proxy_user_id"
|
|
||||||
value={form.proxy_user_id}
|
value={form.proxy_user_id}
|
||||||
onChange={(e) => setForm({ ...form, proxy_user_id: e.target.value })}
|
onValueChange={(v) => setForm({ ...form, proxy_user_id: v })}
|
||||||
placeholder="예: hjkim"
|
>
|
||||||
autoFocus
|
<SelectTrigger id="proxy_user_id" autoFocus>
|
||||||
/>
|
<SelectValue
|
||||||
|
placeholder={
|
||||||
|
candidatesLoading
|
||||||
|
? "사용자 목록 불러오는 중..."
|
||||||
|
: candidates.length === 0
|
||||||
|
? "지정 가능한 사용자가 없습니다"
|
||||||
|
: "대무자를 선택하세요"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{candidates.map((u) => (
|
||||||
|
<SelectItem key={u.user_id} value={u.user_id}>
|
||||||
|
{u.user_name || u.user_id}
|
||||||
|
<span className="ml-1 text-muted-foreground">
|
||||||
|
({u.user_id}
|
||||||
|
{u.dept_name ? ` · ${u.dept_name}` : ""})
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
<p className="mt-1 text-[0.7rem] text-muted-foreground">
|
<p className="mt-1 text-[0.7rem] text-muted-foreground">
|
||||||
같은 회사 사용자만 지정 가능. SUPER_ADMIN 은 지정 불가.
|
같은 회사 활성 사용자만 표시. 본인·SUPER_ADMIN 자동 제외.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -17,10 +17,12 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
|
|||||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||||
import { Check, ChevronsUpDown } from "lucide-react";
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
|
import { Check, ChevronsUpDown, AlertTriangle } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { ColumnTypeInfo, TableInfo, SecondLevelMenu } from "./types";
|
import type { ColumnTypeInfo, TableInfo, SecondLevelMenu } from "./types";
|
||||||
import { INPUT_TYPE_COLORS } from "./types";
|
import { INPUT_TYPE_COLORS, USER_SELECTABLE_INPUT_TYPE_COLORS } from "./types";
|
||||||
|
import { USER_SELECTABLE_INPUT_TYPE_ORDER, isUserSelectableInputType } from "@/types/input-types";
|
||||||
import type { ReferenceTableColumn } from "@/lib/api/entityJoin";
|
import type { ReferenceTableColumn } from "@/lib/api/entityJoin";
|
||||||
|
|
||||||
export interface ColumnDetailPanelProps {
|
export interface ColumnDetailPanelProps {
|
||||||
@@ -54,6 +56,7 @@ export function ColumnDetailPanel({
|
|||||||
const [entityColumnOpen, setEntityColumnOpen] = React.useState(false);
|
const [entityColumnOpen, setEntityColumnOpen] = React.useState(false);
|
||||||
|
|
||||||
const typeConf = column ? INPUT_TYPE_COLORS[column.input_type || "text"] : null;
|
const typeConf = column ? INPUT_TYPE_COLORS[column.input_type || "text"] : null;
|
||||||
|
const isLegacy = column ? !isUserSelectableInputType(column.input_type || "text") : false;
|
||||||
const refColumns = column?.reference_table
|
const refColumns = column?.reference_table
|
||||||
? referenceTableColumns[column.reference_table] ?? []
|
? referenceTableColumns[column.reference_table] ?? []
|
||||||
: [];
|
: [];
|
||||||
@@ -131,19 +134,43 @@ export function ColumnDetailPanel({
|
|||||||
<p className="text-sm font-semibold">이 필드는 어떤 유형인가요?</p>
|
<p className="text-sm font-semibold">이 필드는 어떤 유형인가요?</p>
|
||||||
<p className="text-xs text-muted-foreground">유형에 따라 입력 방식이 바뀌어요</p>
|
<p className="text-xs text-muted-foreground">유형에 따라 입력 방식이 바뀌어요</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-3 gap-1.5">
|
{isLegacy && (
|
||||||
{Object.entries(INPUT_TYPE_COLORS).map(([type, conf]) => {
|
<Alert
|
||||||
|
variant="default"
|
||||||
|
className="border-amber-300/60 bg-amber-50 text-amber-900 dark:border-amber-400/40 dark:bg-amber-950/40 dark:text-amber-100"
|
||||||
|
style={{ boxShadow: "var(--v5-glow-sm)" }}
|
||||||
|
>
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
<AlertTitle>Legacy 입력 타입</AlertTitle>
|
||||||
|
<AlertDescription className="text-amber-900/90 dark:text-amber-100/90">
|
||||||
|
이 컬럼은 legacy 타입 <code className="rounded bg-amber-100 px-1 font-mono text-[11px] dark:bg-amber-900/50">{column.input_type}</code> 입니다.
|
||||||
|
변경하려면 컬럼을 삭제 후 재생성하세요. 다른 속성(길이/필수 등)은 정상 편집 가능합니다.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"grid grid-cols-3 gap-1.5",
|
||||||
|
isLegacy && "pointer-events-none opacity-50",
|
||||||
|
)}
|
||||||
|
aria-disabled={isLegacy}
|
||||||
|
>
|
||||||
|
{USER_SELECTABLE_INPUT_TYPE_ORDER.map((type) => {
|
||||||
|
const conf = USER_SELECTABLE_INPUT_TYPE_COLORS[type];
|
||||||
|
if (!conf) return null;
|
||||||
const isSelected = (column.input_type || "text") === type;
|
const isSelected = (column.input_type || "text") === type;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={type}
|
key={type}
|
||||||
type="button"
|
type="button"
|
||||||
|
disabled={isLegacy}
|
||||||
onClick={() => onColumnChange("input_type", type)}
|
onClick={() => onColumnChange("input_type", type)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col items-center gap-1 rounded-lg border px-1.5 py-2.5 text-center transition-all",
|
"flex flex-col items-center gap-1 rounded-lg border px-1.5 py-2.5 text-center transition-all",
|
||||||
isSelected
|
isSelected
|
||||||
? "border-primary bg-primary/5 ring-1 ring-primary/30"
|
? "border-primary bg-primary/5 ring-1 ring-primary/30"
|
||||||
: "border-border hover:border-primary/30 hover:bg-accent/50",
|
: "border-border hover:border-primary/30 hover:bg-accent/50",
|
||||||
|
isLegacy && "cursor-not-allowed",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import React, { useMemo } from "react";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { ColumnTypeInfo } from "./types";
|
import type { ColumnTypeInfo } from "./types";
|
||||||
import { INPUT_TYPE_COLORS } from "./types";
|
import { INPUT_TYPE_COLORS } from "./types";
|
||||||
|
import { USER_SELECTABLE_INPUT_TYPE_ORDER } from "@/types/input-types";
|
||||||
|
|
||||||
export interface TypeOverviewStripProps {
|
export interface TypeOverviewStripProps {
|
||||||
columns: ColumnTypeInfo[];
|
columns: ColumnTypeInfo[];
|
||||||
@@ -21,10 +22,9 @@ function countByInputType(columns: ColumnTypeInfo[]): Record<string, number> {
|
|||||||
return counts;
|
return counts;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 도넛 차트용 비율 (0~1) 배열 및 라벨 순서 */
|
/** 도넛 차트용 비율 (0~1) 배열 및 라벨 순서 (8개 사용자 선택 가능 타입 한정) */
|
||||||
function getDonutSegments(counts: Record<string, number>, total: number): Array<{ type: string; ratio: number }> {
|
function getDonutSegments(counts: Record<string, number>, total: number): Array<{ type: string; ratio: number }> {
|
||||||
const order = Object.keys(INPUT_TYPE_COLORS);
|
return (USER_SELECTABLE_INPUT_TYPE_ORDER as readonly string[])
|
||||||
return order
|
|
||||||
.filter((type) => (counts[type] || 0) > 0)
|
.filter((type) => (counts[type] || 0) > 0)
|
||||||
.map((type) => ({ type, ratio: (counts[type] || 0) / total }));
|
.map((type) => ({ type, ratio: (counts[type] || 0) / total }));
|
||||||
}
|
}
|
||||||
@@ -84,11 +84,12 @@ export function TypeOverviewStrip({
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 타입 칩 목록 (클릭 시 필터 토글) */}
|
{/* 타입 칩 목록 (8개 사용자 선택 가능 타입 한정, 클릭 시 필터 토글) */}
|
||||||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
|
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
|
||||||
{Object.entries(counts)
|
{(USER_SELECTABLE_INPUT_TYPE_ORDER as readonly string[])
|
||||||
.sort((a, b) => (b[1] ?? 0) - (a[1] ?? 0))
|
.filter((type) => (counts[type] || 0) > 0)
|
||||||
.map(([type]) => {
|
.sort((a, b) => (counts[b] ?? 0) - (counts[a] ?? 0))
|
||||||
|
.map((type) => {
|
||||||
const conf = INPUT_TYPE_COLORS[type] || { color: "text-muted-foreground", bgColor: "bg-muted", label: type };
|
const conf = INPUT_TYPE_COLORS[type] || { color: "text-muted-foreground", bgColor: "bg-muted", label: type };
|
||||||
const isActive = activeFilter === null || activeFilter === type;
|
const isActive = activeFilter === null || activeFilter === type;
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
* page.tsx에서 추출한 인터페이스 및 타입별 색상/그룹 유틸
|
* page.tsx에서 추출한 인터페이스 및 타입별 색상/그룹 유틸
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { USER_SELECTABLE_INPUT_TYPE_ORDER } from "@/types/input-types";
|
||||||
|
|
||||||
export interface TableInfo {
|
export interface TableInfo {
|
||||||
table_name: string;
|
table_name: string;
|
||||||
display_name: string;
|
display_name: string;
|
||||||
@@ -70,6 +72,15 @@ export const INPUT_TYPE_COLORS: Record<string, TypeColorConfig> = {
|
|||||||
image: { color: "text-sky-600", bgColor: "bg-sky-50", barColor: "bg-sky-500", label: "이미지", desc: "이미지 표시", iconChar: "🖼" },
|
image: { color: "text-sky-600", bgColor: "bg-sky-50", barColor: "bg-sky-500", label: "이미지", desc: "이미지 표시", iconChar: "🖼" },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** v3.2 — 사용자 선택 가능한 8개 입력 타입 색상 맵 (T2 드롭다운/카드 그리드용) */
|
||||||
|
export const USER_SELECTABLE_INPUT_TYPE_COLORS = USER_SELECTABLE_INPUT_TYPE_ORDER.reduce(
|
||||||
|
(acc, key) => {
|
||||||
|
if (INPUT_TYPE_COLORS[key]) acc[key] = INPUT_TYPE_COLORS[key];
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, TypeColorConfig>,
|
||||||
|
);
|
||||||
|
|
||||||
/** 컬럼 그룹 판별 */
|
/** 컬럼 그룹 판별 */
|
||||||
export function getColumnGroup(col: ColumnTypeInfo): ColumnGroup {
|
export function getColumnGroup(col: ColumnTypeInfo): ColumnGroup {
|
||||||
const metaCols = ["id", "created_date", "updated_date", "writer", "company_code"];
|
const metaCols = ["id", "created_date", "updated_date", "writer", "company_code"];
|
||||||
|
|||||||
@@ -110,11 +110,17 @@ export function ResponsiveDataView<T>({
|
|||||||
// --- 로딩 스켈레톤 ---
|
// --- 로딩 스켈레톤 ---
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<>
|
<div
|
||||||
|
className={cn(
|
||||||
|
"@container",
|
||||||
|
scrollContainer && "flex min-h-0 flex-1 flex-col"
|
||||||
|
)}
|
||||||
|
>
|
||||||
{/* 데스크톱 테이블 스켈레톤 */}
|
{/* 데스크톱 테이블 스켈레톤 */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"hidden rounded-lg border bg-card shadow-sm lg:block",
|
"hidden rounded-lg border bg-card shadow-sm @3xl:block",
|
||||||
|
scrollContainer && "min-h-0 flex-1 overflow-y-auto",
|
||||||
tableContainerClassName
|
tableContainerClassName
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -162,10 +168,12 @@ export function ResponsiveDataView<T>({
|
|||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 모바일 카드 스켈레톤 */}
|
{/* 모바일 카드 스켈레톤 — container query 기반:
|
||||||
|
컨테이너 < 32rem(512px) = 1열, 32~48rem = 2열, ≥ 48rem(768px) = 테이블 */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"grid gap-4 sm:grid-cols-2 lg:hidden",
|
"grid gap-4 @lg:grid-cols-2 @3xl:hidden",
|
||||||
|
scrollContainer && "min-h-0 flex-1 overflow-y-auto",
|
||||||
cardContainerClassName
|
cardContainerClassName
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -198,7 +206,7 @@ export function ResponsiveDataView<T>({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,16 +220,22 @@ export function ResponsiveDataView<T>({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- 실제 데이터 렌더링 ---
|
// --- 실제 데이터 렌더링 ---
|
||||||
|
// 부모 wrapper 가 @container — 자식들은 viewport 가 아닌 자기 컨테이너 width 기준으로 분기.
|
||||||
|
// 사이드바 펼친 상태에서도 콘텐츠 영역 실제 width 에 맞게 자연스럽게 테이블↔카드 전환.
|
||||||
return (
|
return (
|
||||||
<>
|
<div
|
||||||
{/* 데스크톱 테이블 (lg 이상) */}
|
className={cn(
|
||||||
|
"@container",
|
||||||
|
// scrollContainer 모드: 부모 flex-col 의 남는 공간을 차지 → 페이지네이션 등 형제는 자기 height 유지
|
||||||
|
scrollContainer && "flex min-h-0 flex-1 flex-col"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* 데스크톱 테이블 (컨테이너 ≥ 48rem / 768px) */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"hidden rounded-lg border bg-card shadow-sm lg:block",
|
"hidden rounded-lg border bg-card shadow-sm @3xl:block",
|
||||||
// scrollContainer 모드: 뷰포트 기반 max-height + 자체 세로 스크롤 + sticky 헤더.
|
// scrollContainer 모드: 부모 flex 안에서 가용 height 다 차지 + 자체 세로 스크롤 + sticky 헤더
|
||||||
// flex 기반 계산이 shadcn Table 의 내부 wrapper(overflow-x-auto) 와 충돌해
|
scrollContainer && "min-h-0 flex-1 overflow-y-auto overflow-x-auto",
|
||||||
// 신뢰성 떨어지므로 viewport 기준으로 명시. 페이지 헤더/툴바/페이지네이션 약 280px 가정.
|
|
||||||
scrollContainer && "max-h-[calc(100vh-280px)] overflow-y-auto overflow-x-auto",
|
|
||||||
tableContainerClassName
|
tableContainerClassName
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -282,10 +296,12 @@ export function ResponsiveDataView<T>({
|
|||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 모바일 카드 (lg 미만) */}
|
{/* 모바일 카드 (컨테이너 < 48rem) — < 32rem 1열, 32~48rem 2열 */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"grid gap-4 sm:grid-cols-2 lg:hidden",
|
"grid gap-4 @lg:grid-cols-2 @3xl:hidden",
|
||||||
|
// scrollContainer 모드: 부모 flex 안에서 남는 공간 다 차지 + 자체 세로 스크롤
|
||||||
|
scrollContainer && "min-h-0 flex-1 overflow-y-auto",
|
||||||
cardContainerClassName
|
cardContainerClassName
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -341,7 +357,7 @@ export function ResponsiveDataView<T>({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -292,7 +292,7 @@ export function TableHistoryModal({
|
|||||||
|
|
||||||
{/* 타임라인 뷰 */}
|
{/* 타임라인 뷰 */}
|
||||||
<TabsContent value="timeline">
|
<TabsContent value="timeline">
|
||||||
<ScrollArea className="h-[500px] w-full rounded-md border p-4">
|
<ScrollArea className="h-[300px] w-full rounded-md border p-4 sm:h-[500px]">
|
||||||
{timeline.length === 0 ? (
|
{timeline.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
<Clock className="text-muted-foreground mb-2 h-12 w-12" />
|
<Clock className="text-muted-foreground mb-2 h-12 w-12" />
|
||||||
@@ -347,7 +347,7 @@ export function TableHistoryModal({
|
|||||||
|
|
||||||
{/* 상세 내역 뷰 */}
|
{/* 상세 내역 뷰 */}
|
||||||
<TabsContent value="detail">
|
<TabsContent value="detail">
|
||||||
<ScrollArea className="h-[500px] w-full rounded-md border">
|
<ScrollArea className="h-[300px] w-full rounded-md border sm:h-[500px]">
|
||||||
{detailRecords.length === 0 ? (
|
{detailRecords.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
<FileEdit className="text-muted-foreground mb-2 h-12 w-12" />
|
<FileEdit className="text-muted-foreground mb-2 h-12 w-12" />
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ import {
|
|||||||
} from "@/lib/api/screenGroup";
|
} from "@/lib/api/screenGroup";
|
||||||
import { tableManagementApi, ColumnTypeInfo, TableInfo, ColumnSettings } from "@/lib/api/tableManagement";
|
import { tableManagementApi, ColumnTypeInfo, TableInfo, ColumnSettings } from "@/lib/api/tableManagement";
|
||||||
import { screenApi } from "@/lib/api/screen";
|
import { screenApi } from "@/lib/api/screen";
|
||||||
import { INPUT_TYPE_OPTIONS } from "@/types/input-types";
|
import { INPUT_TYPE_OPTIONS, USER_SELECTABLE_INPUT_TYPE_ORDER } from "@/types/input-types";
|
||||||
import TableManagementPage from "@/app/(main)/admin/systemMng/tableMngList/page";
|
import TableManagementPage from "@/app/(main)/admin/systemMng/tableMngList/page";
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -621,13 +621,15 @@ export function TableSettingModal({
|
|||||||
[tables]
|
[tables]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 입력 타입 옵션
|
// 입력 타입 옵션 (8개 사용자 선택 가능 — 박창현 Q4: 일관성)
|
||||||
const inputTypeOptions = useMemo(
|
const inputTypeOptions = useMemo(
|
||||||
() =>
|
() =>
|
||||||
INPUT_TYPE_OPTIONS.map((opt) => ({
|
INPUT_TYPE_OPTIONS
|
||||||
value: opt.value,
|
.filter((opt) => USER_SELECTABLE_INPUT_TYPE_ORDER.includes(opt.value as any))
|
||||||
label: opt.label,
|
.map((opt) => ({
|
||||||
})),
|
value: opt.value,
|
||||||
|
label: opt.label,
|
||||||
|
})),
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -199,7 +199,10 @@ export async function getUserHistory(userId: string, params?: Record<string, any
|
|||||||
* 사용자 비밀번호 초기화
|
* 사용자 비밀번호 초기화
|
||||||
*/
|
*/
|
||||||
export async function resetUserPassword(resetData: { userId: string; newPassword: string }) {
|
export async function resetUserPassword(resetData: { userId: string; newPassword: string }) {
|
||||||
const response = await apiClient.post("/admin/users/reset-password", resetData);
|
const response = await apiClient.post("/admin/users/reset-password", {
|
||||||
|
user_id: resetData.userId,
|
||||||
|
new_password: resetData.newPassword,
|
||||||
|
});
|
||||||
|
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import {
|
||||||
|
INPUT_TYPE_DETAIL_TYPES,
|
||||||
|
type UserSelectableInputType,
|
||||||
|
type WidgetVariantOption,
|
||||||
|
isUserSelectableInputType,
|
||||||
|
} from "@/types/input-type-mapping";
|
||||||
|
|
||||||
|
/** base 가 8개 안에 있을 때만 variant 목록 반환 */
|
||||||
|
export function getWidgetVariants(baseInputType: string): WidgetVariantOption[] {
|
||||||
|
if (!isUserSelectableInputType(baseInputType)) return [];
|
||||||
|
return INPUT_TYPE_DETAIL_TYPES[baseInputType as UserSelectableInputType] ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** base 의 기본 variant 반환 (variant 목록의 첫 번째) */
|
||||||
|
export function getDefaultWidgetVariant(baseInputType: string): string {
|
||||||
|
const variants = getWidgetVariants(baseInputType);
|
||||||
|
return variants[0]?.value ?? baseInputType;
|
||||||
|
}
|
||||||
@@ -1,194 +1,186 @@
|
|||||||
/**
|
/** 박창현 image 2 의 8개 — 사용자가 테이블 타입 관리에서 직접 고를 수 있는 base */
|
||||||
* 입력 타입(Input Type)과 세부 타입(Detail Type) 매핑 정의
|
export type UserSelectableInputType =
|
||||||
*
|
| "text" | "number" | "date"
|
||||||
* 테이블 타입 관리의 8개 핵심 입력 타입을 기반으로
|
| "code" | "entity"
|
||||||
* 화면 관리에서 선택 가능한 세부 타입들을 정의합니다.
|
| "numbering" | "file" | "image";
|
||||||
*/
|
|
||||||
|
|
||||||
import { WebType } from "./v2-core";
|
/** 화면관리 widget 의 세부 variant */
|
||||||
|
export interface WidgetVariantOption {
|
||||||
/**
|
value: string;
|
||||||
* 핵심 입력 타입
|
|
||||||
*/
|
|
||||||
export type BaseInputType =
|
|
||||||
| "text" // 텍스트
|
|
||||||
| "textarea" // 텍스트 에리어 (여러 줄)
|
|
||||||
| "number" // 숫자
|
|
||||||
| "date" // 날짜
|
|
||||||
| "code" // 코드
|
|
||||||
| "entity" // 엔티티
|
|
||||||
| "select" // 선택박스
|
|
||||||
| "checkbox" // 체크박스
|
|
||||||
| "radio" // 라디오버튼
|
|
||||||
| "image"; // 이미지
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 세부 타입 옵션 인터페이스
|
|
||||||
*/
|
|
||||||
export interface DetailTypeOption {
|
|
||||||
value: WebType;
|
|
||||||
label: string;
|
label: string;
|
||||||
description: string;
|
description: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 입력 타입별 세부 타입 매핑
|
* 8개 base 별 widget variant 매핑.
|
||||||
|
* vexplor_rps `input-type-mapping.ts:37-102` 의 variant 그대로 포팅.
|
||||||
|
* 박창현 결정 (Q3): select/checkbox/radio base 의 variant 는 모두 code base 로 흡수.
|
||||||
*/
|
*/
|
||||||
export const INPUT_TYPE_DETAIL_TYPES: Record<BaseInputType, DetailTypeOption[]> = {
|
export const INPUT_TYPE_DETAIL_TYPES: Record<UserSelectableInputType, WidgetVariantOption[]> = {
|
||||||
// 텍스트 → text, email, tel, url, password
|
|
||||||
text: [
|
text: [
|
||||||
{ value: "text", label: "일반 텍스트", description: "기본 텍스트 입력" },
|
{ value: "text", label: "일반 텍스트", description: "기본 텍스트 입력" },
|
||||||
{ value: "email", label: "이메일", description: "이메일 주소 입력" },
|
{ value: "email", label: "이메일", description: "이메일 주소 입력" },
|
||||||
{ value: "tel", label: "전화번호", description: "전화번호 입력" },
|
{ value: "tel", label: "전화번호", description: "전화번호 입력" },
|
||||||
{ value: "url", label: "URL", description: "웹사이트 주소 입력" },
|
{ value: "url", label: "URL", description: "웹사이트 주소 입력" },
|
||||||
{ value: "password", label: "비밀번호", description: "비밀번호 입력 (마스킹)" },
|
{ value: "password", label: "비밀번호", description: "마스킹 입력" },
|
||||||
|
{ value: "textarea", label: "여러 줄 텍스트", description: "긴 텍스트 영역" },
|
||||||
],
|
],
|
||||||
|
|
||||||
// 텍스트 에리어 → textarea
|
|
||||||
textarea: [{ value: "textarea", label: "텍스트 에리어", description: "여러 줄 텍스트 입력" }],
|
|
||||||
|
|
||||||
// 숫자 → number, decimal, currency, percentage
|
|
||||||
number: [
|
number: [
|
||||||
{ value: "number", label: "정수", description: "정수 숫자 입력" },
|
{ value: "number", label: "정수", description: "정수 숫자 입력" },
|
||||||
{ value: "decimal", label: "소수", description: "소수점 포함 숫자 입력" },
|
{ value: "decimal", label: "소수", description: "소수점 포함" },
|
||||||
{ value: "currency", label: "통화", description: "통화 형식 (₩ 1,000)" },
|
{ value: "currency", label: "통화", description: "₩ 1,000 형식" },
|
||||||
{ value: "percentage", label: "퍼센트", description: "퍼센트 형식 (50%)" },
|
{ value: "percentage", label: "퍼센트", description: "50% 형식" },
|
||||||
],
|
],
|
||||||
|
|
||||||
// 날짜 → date, datetime, time, daterange, month, year
|
|
||||||
date: [
|
date: [
|
||||||
{ value: "date", label: "날짜", description: "날짜 선택 (YYYY-MM-DD)" },
|
{ value: "date", label: "날짜", description: "YYYY-MM-DD" },
|
||||||
{ value: "datetime", label: "날짜+시간", description: "날짜와 시간 선택" },
|
{ value: "datetime", label: "날짜+시간", description: "날짜와 시간" },
|
||||||
{ value: "time", label: "시간", description: "시간 선택 (HH:mm)" },
|
{ value: "time", label: "시간", description: "HH:mm" },
|
||||||
{ value: "daterange", label: "기간", description: "시작일 ~ 종료일" },
|
{ value: "daterange", label: "기간", description: "시작일 ~ 종료일" },
|
||||||
{ value: "month", label: "월", description: "년/월 선택 (YYYY-MM)" },
|
{ value: "month", label: "월", description: "YYYY-MM" },
|
||||||
{ value: "year", label: "년", description: "년도 선택 (YYYY)" },
|
{ value: "year", label: "년", description: "YYYY" },
|
||||||
],
|
],
|
||||||
|
|
||||||
// 코드 → code, code-autocomplete, code-radio
|
|
||||||
code: [
|
code: [
|
||||||
{ value: "code", label: "코드 선택박스", description: "드롭다운으로 코드 선택" },
|
{ value: "code", label: "코드 선택박스", description: "드롭다운으로 코드 선택" },
|
||||||
{ value: "code-autocomplete", label: "코드 자동완성", description: "코드/코드명 검색" },
|
{ value: "code-autocomplete", label: "코드 자동완성", description: "코드/코드명 검색" },
|
||||||
{ value: "code-radio", label: "코드 라디오", description: "라디오 버튼으로 선택" },
|
{ value: "code-radio", label: "코드 라디오", description: "라디오 버튼 그룹" },
|
||||||
|
{ value: "code-radio-horizontal", label: "가로 라디오", description: "가로 배치 라디오" },
|
||||||
|
{ value: "code-radio-vertical", label: "세로 라디오", description: "세로 배치 라디오" },
|
||||||
|
{ value: "dropdown", label: "검색 선택박스", description: "검색 기능 포함 (vexplor_rps select base)" },
|
||||||
|
{ value: "multiselect", label: "다중 선택", description: "여러 항목 선택 (vexplor_rps select base)" },
|
||||||
|
{ value: "autocomplete", label: "자동완성", description: "입력하면 자동완성 제안 (vexplor_rps select base)" },
|
||||||
|
{ value: "checkbox", label: "체크박스", description: "단일 체크박스 (vexplor_rps checkbox base)" },
|
||||||
|
{ value: "checkbox-group", label: "체크박스 그룹", description: "여러 체크박스 (vexplor_rps checkbox base)" },
|
||||||
|
{ value: "boolean", label: "On/Off 스위치", description: "boolean 스위치 (vexplor_rps checkbox base)" },
|
||||||
],
|
],
|
||||||
|
entity: [
|
||||||
// 엔티티 → entity (세부 타입 없음, 참조 테이블만 선택)
|
{ value: "entity", label: "엔티티 참조", description: "다른 테이블 데이터 참조" },
|
||||||
entity: [{ value: "entity", label: "엔티티 참조", description: "다른 테이블 데이터 참조" }],
|
{ value: "entity-autocomplete", label: "엔티티 자동완성", description: "검색 결과 inline 표시" },
|
||||||
|
{ value: "entity-popup", label: "엔티티 팝업", description: "별도 검색 팝업" },
|
||||||
// 선택박스 → select, dropdown, multiselect, autocomplete
|
|
||||||
select: [
|
|
||||||
{ value: "select", label: "선택박스", description: "드롭다운 선택 (단일)" },
|
|
||||||
{ value: "dropdown", label: "검색 선택박스", description: "검색 기능 포함" },
|
|
||||||
{ value: "multiselect", label: "다중 선택", description: "여러 항목 선택 (태그)" },
|
|
||||||
{ value: "autocomplete", label: "자동완성", description: "입력하면 자동완성 제안" },
|
|
||||||
],
|
],
|
||||||
|
file: [
|
||||||
// 체크박스 → checkbox, boolean, checkbox-group
|
{ value: "file", label: "단일 파일", description: "파일 1개 첨부" },
|
||||||
checkbox: [
|
{ value: "file-list", label: "다중 파일", description: "여러 파일 첨부" },
|
||||||
{ value: "checkbox", label: "체크박스", description: "단일 체크박스" },
|
{ value: "file-drop", label: "드래그 앤 드롭", description: "drop zone 형태" },
|
||||||
{ value: "boolean", label: "스위치", description: "On/Off 스위치" },
|
|
||||||
{ value: "checkbox-group", label: "체크박스 그룹", description: "여러 체크박스" },
|
|
||||||
],
|
],
|
||||||
|
image: [
|
||||||
// 라디오버튼 → radio, radio-horizontal, radio-vertical
|
{ value: "image", label: "이미지", description: "이미지 1장 표시/업로드" },
|
||||||
radio: [
|
{ value: "image-gallery", label: "이미지 갤러리", description: "여러 장 grid 표시" },
|
||||||
{ value: "radio", label: "라디오버튼", description: "기본 라디오 버튼" },
|
{ value: "image-upload", label: "이미지 업로드", description: "크롭/리사이즈 포함" },
|
||||||
{ value: "radio-horizontal", label: "가로 라디오", description: "가로 배치" },
|
],
|
||||||
{ value: "radio-vertical", label: "세로 라디오", description: "세로 배치" },
|
numbering: [
|
||||||
|
{ value: "numbering", label: "자동 채번", description: "옵션설정의 채번 규칙으로 자동 생성" },
|
||||||
],
|
],
|
||||||
|
|
||||||
// 이미지 → image
|
|
||||||
image: [{ value: "image", label: "이미지", description: "이미지 URL 표시" }],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/** 8개 base 의 한글 라벨 */
|
||||||
* 웹타입에서 기본 입력 타입 추출
|
export const USER_SELECTABLE_INPUT_TYPE_LABELS: Record<UserSelectableInputType, string> = {
|
||||||
*/
|
text: "텍스트",
|
||||||
export function getBaseInputType(webType: WebType): BaseInputType {
|
number: "숫자",
|
||||||
// textarea (별도 타입으로 분리)
|
date: "날짜",
|
||||||
if (webType === "textarea") {
|
code: "코드",
|
||||||
return "textarea";
|
entity: "테이블참조",
|
||||||
}
|
numbering: "채번",
|
||||||
|
file: "파일",
|
||||||
|
image: "이미지",
|
||||||
|
};
|
||||||
|
|
||||||
// text 계열
|
/** 8개 base 의 순서 (드롭다운/카드 표시 순서) */
|
||||||
if (["text", "email", "tel", "url", "password"].includes(webType)) {
|
export const USER_SELECTABLE_INPUT_TYPE_ORDER: UserSelectableInputType[] = [
|
||||||
return "text";
|
"text", "number", "date",
|
||||||
}
|
"code", "entity",
|
||||||
|
"numbering", "file", "image",
|
||||||
|
];
|
||||||
|
|
||||||
// number 계열
|
export function isUserSelectableInputType(value: string): value is UserSelectableInputType {
|
||||||
if (["number", "decimal", "currency", "percentage"].includes(webType)) {
|
return USER_SELECTABLE_INPUT_TYPE_ORDER.includes(value as UserSelectableInputType);
|
||||||
return "number";
|
|
||||||
}
|
|
||||||
|
|
||||||
// date 계열
|
|
||||||
if (["date", "datetime", "time", "daterange", "month", "year"].includes(webType)) {
|
|
||||||
return "date";
|
|
||||||
}
|
|
||||||
|
|
||||||
// code 계열
|
|
||||||
if (["code", "code-autocomplete", "code-radio"].includes(webType)) {
|
|
||||||
return "code";
|
|
||||||
}
|
|
||||||
|
|
||||||
// select 계열
|
|
||||||
if (["select", "dropdown", "multiselect", "autocomplete"].includes(webType)) {
|
|
||||||
return "select";
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkbox 계열
|
|
||||||
if (["checkbox", "boolean", "checkbox-group"].includes(webType)) {
|
|
||||||
return "checkbox";
|
|
||||||
}
|
|
||||||
|
|
||||||
// radio 계열
|
|
||||||
if (["radio", "radio-horizontal", "radio-vertical"].includes(webType)) {
|
|
||||||
return "radio";
|
|
||||||
}
|
|
||||||
|
|
||||||
// entity
|
|
||||||
if (webType === "entity") return "entity";
|
|
||||||
|
|
||||||
// image
|
|
||||||
if (webType === "image") return "image";
|
|
||||||
|
|
||||||
// 기본값: text
|
|
||||||
return "text";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
* 입력 타입에 해당하는 세부 타입 목록 가져오기
|
// Backward Shim — 사용처 3곳 (V2PropertiesPanel/PropertiesPanel/DetailSettingsPanel) 임시 호환
|
||||||
*/
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
export function getDetailTypes(baseInputType: BaseInputType): DetailTypeOption[] {
|
|
||||||
return INPUT_TYPE_DETAIL_TYPES[baseInputType] || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 입력 타입에 해당하는 기본 세부 타입 가져오기
|
* @deprecated v3.2: BaseInputType 은 UserSelectableInputType (8개) 로 대체.
|
||||||
|
* 이 export 는 backward shim — 사용처 3곳 임시 호환. 후속 PR 에서 strangle.
|
||||||
*/
|
*/
|
||||||
export function getDefaultDetailType(baseInputType: BaseInputType): WebType {
|
export type BaseInputType =
|
||||||
const detailTypes = getDetailTypes(baseInputType);
|
| UserSelectableInputType
|
||||||
return detailTypes[0]?.value || "text";
|
| "textarea"
|
||||||
}
|
| "select"
|
||||||
|
| "checkbox"
|
||||||
|
| "radio";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 입력 타입 옵션 (PropertiesPanel에서 사용)
|
* @deprecated v3.2: WidgetVariantOption 로 대체.
|
||||||
|
* 사용처 3곳의 DetailTypeOption import 호환용.
|
||||||
|
*/
|
||||||
|
export type DetailTypeOption = WidgetVariantOption;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated v3.2: UserSelectableInputType 의 8개로 사용 권장.
|
||||||
|
* 사용처 3곳의 BASE_INPUT_TYPE_OPTIONS import 호환용.
|
||||||
*/
|
*/
|
||||||
export const BASE_INPUT_TYPE_OPTIONS: Array<{ value: BaseInputType; label: string; description: string }> = [
|
export const BASE_INPUT_TYPE_OPTIONS: Array<{ value: BaseInputType; label: string; description: string }> = [
|
||||||
{ value: "text", label: "텍스트", description: "텍스트 입력 필드" },
|
{ value: "text", label: "텍스트", description: "텍스트 입력 필드" },
|
||||||
{ value: "textarea", label: "텍스트 에리어", description: "여러 줄 텍스트 입력" },
|
{ value: "textarea", label: "텍스트 에리어", description: "여러 줄 텍스트 입력 (legacy — text base 의 variant)" },
|
||||||
{ value: "number", label: "숫자", description: "숫자 입력 필드" },
|
{ value: "number", label: "숫자", description: "숫자 입력 필드" },
|
||||||
{ value: "date", label: "날짜", description: "날짜/시간 선택" },
|
{ value: "date", label: "날짜", description: "날짜/시간 선택" },
|
||||||
{ value: "code", label: "코드", description: "공통 코드 선택" },
|
{ value: "code", label: "코드", description: "공통 코드 선택" },
|
||||||
{ value: "entity", label: "엔티티", description: "다른 테이블 참조" },
|
{ value: "entity", label: "엔티티", description: "다른 테이블 참조" },
|
||||||
{ value: "select", label: "선택박스", description: "드롭다운 선택" },
|
{ value: "select", label: "선택박스", description: "드롭다운 선택 (legacy — code base 의 variant)" },
|
||||||
{ value: "checkbox", label: "체크박스", description: "체크박스/스위치" },
|
{ value: "checkbox", label: "체크박스", description: "체크박스/스위치 (legacy — code base 의 variant)" },
|
||||||
{ value: "radio", label: "라디오버튼", description: "라디오 버튼 그룹" },
|
{ value: "radio", label: "라디오버튼", description: "라디오 버튼 그룹 (legacy — code base 의 variant)" },
|
||||||
{ value: "image", label: "이미지", description: "이미지 표시" },
|
{ value: "image", label: "이미지", description: "이미지 표시" },
|
||||||
|
{ value: "numbering", label: "자동 채번", description: "옵션설정 기반 자동 번호" },
|
||||||
|
{ value: "file", label: "파일", description: "파일 업로드" },
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 입력 타입 검증
|
* @deprecated v3.2: webType → UserSelectableInputType 로 매핑 권장 (`getUserSelectableInputType`).
|
||||||
|
* 사용처 3곳의 getBaseInputType 호환용.
|
||||||
*/
|
*/
|
||||||
export function isValidBaseInputType(value: string): value is BaseInputType {
|
export function getBaseInputType(webType: string): BaseInputType {
|
||||||
return BASE_INPUT_TYPE_OPTIONS.some((opt) => opt.value === value);
|
if (["text", "email", "tel", "url", "password"].includes(webType)) return "text";
|
||||||
|
if (webType === "textarea") return "textarea";
|
||||||
|
if (["number", "decimal", "currency", "percentage"].includes(webType)) return "number";
|
||||||
|
if (["date", "datetime", "time", "daterange", "month", "year"].includes(webType)) return "date";
|
||||||
|
if (["code", "code-autocomplete", "code-radio", "code-radio-horizontal", "code-radio-vertical"].includes(webType)) return "code";
|
||||||
|
if (["select", "dropdown", "multiselect", "autocomplete"].includes(webType)) return "select";
|
||||||
|
if (["checkbox", "boolean", "checkbox-group"].includes(webType)) return "checkbox";
|
||||||
|
if (["radio", "radio-horizontal", "radio-vertical"].includes(webType)) return "radio";
|
||||||
|
if (webType === "entity" || webType.startsWith("entity-")) return "entity";
|
||||||
|
if (webType === "image" || webType.startsWith("image-")) return "image";
|
||||||
|
if (webType === "file" || webType.startsWith("file-")) return "file";
|
||||||
|
if (webType === "numbering") return "numbering";
|
||||||
|
return "text";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 신규 권장 함수 — UserSelectableInputType 직접 반환 */
|
||||||
|
export function getUserSelectableInputType(webType: string): UserSelectableInputType {
|
||||||
|
const base = getBaseInputType(webType);
|
||||||
|
if (base === "textarea") return "text";
|
||||||
|
if (base === "select" || base === "checkbox" || base === "radio") return "code";
|
||||||
|
return base as UserSelectableInputType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated v3.2: getWidgetVariants (lib/utils/getDetailType.ts) 권장.
|
||||||
|
* 사용처의 getDetailTypes import 호환용.
|
||||||
|
*/
|
||||||
|
export function getDetailTypes(baseInputType: BaseInputType): DetailTypeOption[] {
|
||||||
|
// legacy base → UserSelectableInputType 매핑 후 variant 조회
|
||||||
|
let key: UserSelectableInputType;
|
||||||
|
if (baseInputType === "textarea") key = "text";
|
||||||
|
else if (baseInputType === "select" || baseInputType === "checkbox" || baseInputType === "radio") key = "code";
|
||||||
|
else key = baseInputType;
|
||||||
|
return INPUT_TYPE_DETAIL_TYPES[key] ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated v3.2: getDefaultWidgetVariant (lib/utils/getDetailType.ts) 권장.
|
||||||
|
* 사용처의 getDefaultDetailType import 호환용.
|
||||||
|
*/
|
||||||
|
export function getDefaultDetailType(baseInputType: BaseInputType): string {
|
||||||
|
const variants = getDetailTypes(baseInputType);
|
||||||
|
return variants[0]?.value ?? baseInputType;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -311,3 +311,14 @@ export const INPUT_TYPE_VALIDATION_RULES: Record<InputType, Record<string, any>>
|
|||||||
autoGenerate: true,
|
autoGenerate: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// v3.2 — Layer 2 사용자 선택 가능한 8개 (박창현 image 2)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export {
|
||||||
|
USER_SELECTABLE_INPUT_TYPE_ORDER,
|
||||||
|
USER_SELECTABLE_INPUT_TYPE_LABELS,
|
||||||
|
isUserSelectableInputType,
|
||||||
|
type UserSelectableInputType,
|
||||||
|
} from "./input-type-mapping";
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@ metadata:
|
|||||||
data:
|
data:
|
||||||
SPRING_PROFILES_ACTIVE: "prod"
|
SPRING_PROFILES_ACTIVE: "prod"
|
||||||
SERVER_PORT: "8081"
|
SERVER_PORT: "8081"
|
||||||
SPRING_DATASOURCE_URL: "jdbc:postgresql://183.99.177.40:5432/invyone"
|
SPRING_DATASOURCE_URL: "jdbc:postgresql://183.99.177.40:5432/invyone?sslmode=disable"
|
||||||
SPRING_DATASOURCE_USERNAME: "postgres"
|
SPRING_DATASOURCE_USERNAME: "postgres"
|
||||||
JWT_EXPIRATION: "86400000"
|
JWT_EXPIRATION: "86400000"
|
||||||
FILE_UPLOAD_DIR: "./uploads"
|
FILE_UPLOAD_DIR: "./uploads"
|
||||||
|
|||||||
Reference in New Issue
Block a user