> ## Documentation Index
> Fetch the complete documentation index at: https://docs.twenty.com/llms.txt
> Use this file to discover all available pages before exploring further.

# 4. UI 구성하기

> 뷰, 사이드바 내비게이션, 커맨드, 그리고 프런트 컴포넌트.

현재는 설정(Settings)에서만 해당 객체에 접근할 수 있습니다. 이제 앱이 UI에서 진짜 존재감을 갖도록 해 봅시다. 목록 뷰, 사이드바 항목, 원클릭 **Generate document** 명령, 문서를 **미리 보기** 위한 레코드 페이지용 프런트 컴포넌트, 템플릿용 기본 제공 리치 텍스트 **editor** 탭을 추가합니다.

## 뷰와 내비게이션

[뷰](/l/ko/developers/extend/apps/layout/views)는 특정 객체에 대해 저장된 목록입니다.
[내비게이션 메뉴 항목](/l/ko/developers/extend/apps/layout/navigation-menu-items)은 해당 뷰를 사이드바에 배치합니다.

```ts filename="src/views/documents.view.ts" theme={null}
import { defineView, ViewKey } from 'twenty-sdk/define';

export default defineView({
  universalIdentifier: DOCUMENTS_VIEW_UNIVERSAL_IDENTIFIER,
  name: 'All documents',
  objectUniversalIdentifier: DOCUMENT_OBJECT_UNIVERSAL_IDENTIFIER,
  icon: 'IconFile',
  key: ViewKey.INDEX,
  position: 0,
  fields: [
    { universalIdentifier: DOCUMENTS_VIEW_NAME_FIELD_UNIVERSAL_IDENTIFIER,
      fieldMetadataUniversalIdentifier: DOCUMENT_NAME_FIELD_UNIVERSAL_IDENTIFIER,
      position: 0, isVisible: true, size: 280 },
    { universalIdentifier: DOCUMENTS_VIEW_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
      fieldMetadataUniversalIdentifier: DOCUMENT_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
      position: 1, isVisible: true, size: 120 },
    { universalIdentifier: DOCUMENTS_VIEW_TEMPLATE_FIELD_UNIVERSAL_IDENTIFIER,
      fieldMetadataUniversalIdentifier: DOCUMENT_TEMPLATE_FIELD_UNIVERSAL_IDENTIFIER,
      position: 2, isVisible: true, size: 200 },
  ],
});
```

```ts filename="src/navigation-menu-items/documents.navigation-menu-item.ts" theme={null}
import { defineNavigationMenuItem, NavigationMenuItemType } from 'twenty-sdk/define';

export default defineNavigationMenuItem({
  universalIdentifier: DOCUMENTS_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
  name: 'Documents',
  icon: 'IconFile',
  color: 'green',
  position: 1,
  type: NavigationMenuItemType.VIEW,
  viewUniversalIdentifier: DOCUMENTS_VIEW_UNIVERSAL_IDENTIFIER,
});
```

템플릿에 대해서도 동일한 쌍을 추가하세요. 이제 둘 다 사이드바에 표시됩니다:

<Frame caption="사이드바의 Documents와 Templates, 그리고 생성된 문서가 목록에 표시되어 있습니다.">
  <img src="https://mintcdn.com/twenty/sqJBeTZq-W-RDBPU/images/docs/developers/extends/apps/document-generator/04-documents-view.png?fit=max&auto=format&n=sqJBeTZq-W-RDBPU&q=85&s=26d26efeea7825a0e740c5ed21d545f7" alt="생성된 문서가 있는 Documents 뷰" width="2880" height="1800" data-path="images/docs/developers/extends/apps/document-generator/04-documents-view.png" />
</Frame>

## 프런트 컴포넌트

[프런트 컴포넌트](/l/ko/developers/extend/apps/layout/front-components)는 Twenty 안에 샌드박스된 React 컴포넌트입니다. 우리 컴포넌트는 선택된 레코드를 읽고, `CoreApiClient`를 통해 person 템플릿을 로드한 뒤, 이전 장에서 만든 라우트로 POST 요청을 보냅니다.

```tsx filename="src/front-components/generate-document-form.front-component.tsx" theme={null}
import { useEffect, useState } from 'react';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { defineFrontComponent } from 'twenty-sdk/define';
import { enqueueSnackbar, useSelectedRecordIds } from 'twenty-sdk/front-component';

const GenerateDocumentForm = () => {
  const selectedRecordIds = useSelectedRecordIds();
  const recordId = selectedRecordIds.length === 1 ? selectedRecordIds[0] : null;
  const [templates, setTemplates] = useState<{ id: string; name: string }[]>([]);
  const [templateId, setTemplateId] = useState('');

  useEffect(() => {
    new CoreApiClient()
      .query({ documentTemplates: {
        __args: { filter: { target: { eq: 'PERSON' } }, first: 100 },
        edges: { node: { id: true, name: true } } } })
      .then(({ documentTemplates }) => {
        const list = documentTemplates?.edges?.map((e) => e.node) ?? [];
        setTemplates(list);
        if (list[0]) setTemplateId(list[0].id);
      });
  }, []);

  const generate = async () => {
    const apiBaseUrl = process.env.TWENTY_API_URL;
    const token = process.env.TWENTY_APP_ACCESS_TOKEN ?? process.env.TWENTY_API_KEY;
    const res = await fetch(`${apiBaseUrl}/s/documents/generate`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
      body: JSON.stringify({ templateId, recordId }),
    }).then((r) => r.json());
    await enqueueSnackbar({
      message: res.success ? 'Document generated.' : 'Generation failed.',
      variant: res.success ? 'success' : 'error',
    });
  };

  // ...render a <select> of templates and a Generate button
};

export default defineFrontComponent({
  universalIdentifier: GENERATE_DOCUMENT_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
  name: 'generate-document-form',
  component: GenerateDocumentForm,
});
```

<Warning>
  값을 `twenty-ui`에서 임포트하지 말고 인라인 CSS 변수(`var(--t-color-blue)`)로 스타일을 지정하세요. SDK 가 빌드 중에 해당 패키지를 모킹하므로, 테마 상수를 모듈 레벨에서 임포트하면 `undefined`가 됩니다. 자세한 내용은
  [전체 컴포넌트](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/front-components/generate-document-form.front-component.tsx)를 참고하세요.
</Warning>

## 이를 여는 커맨드

`availabilityType: 'RECORD_SELECTION'`이 있는 [커맨드 메뉴 항목](/l/ko/developers/extend/apps/layout/command-menu-items)은 Person 이 선택되었을 때 표시되며, 컴포넌트를 사이드 패널에서 엽니다.

```ts filename="src/command-menu-items/generate-document.command-menu-item.ts" theme={null}
import { defineCommandMenuItem, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk/define';

export default defineCommandMenuItem({
  universalIdentifier: GENERATE_DOCUMENT_COMMAND_UNIVERSAL_IDENTIFIER,
  label: 'Generate document',
  availabilityType: 'RECORD_SELECTION',
  availabilityObjectUniversalIdentifier:
    STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
  frontComponentUniversalIdentifier:
    GENERATE_DOCUMENT_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
});
```

## 전체 플로우 사용해 보기

**People**을 열고, 사람을 하나 선택한 뒤 <kbd>⌘K</kbd> / <kbd>Ctrl K</kbd>를 누르세요.
"Generate document"가 앱 태그와 함께 표시됩니다:

<Frame caption="Person 이 선택되었을 때 이 커맨드가 표시됩니다.">
  <img src="https://mintcdn.com/twenty/sqJBeTZq-W-RDBPU/images/docs/developers/extends/apps/document-generator/06-command-menu.png?fit=max&auto=format&n=sqJBeTZq-W-RDBPU&q=85&s=7f565368b8e4f21b0899b89505cc4f34" alt="Generate document 커맨드가 있는 커맨드 메뉴" width="2880" height="1800" data-path="images/docs/developers/extends/apps/document-generator/06-command-menu.png" />
</Frame>

이 커맨드를 실행하면 컴포넌트가 사이드 패널에서 열립니다. 템플릿을 선택하고 **Generate**를 클릭하면, 새 레코드가 **Documents**에 생성됩니다.

<Frame caption="프런트 컴포넌트가 템플릿을 로드하고 클릭 시 문서를 생성합니다.">
  <img src="https://mintcdn.com/twenty/sqJBeTZq-W-RDBPU/images/docs/developers/extends/apps/document-generator/06b-front-component.png?fit=max&auto=format&n=sqJBeTZq-W-RDBPU&q=85&s=4fb7f799ef6db5e0d336591a8f61f72a" alt="Generate document 사이드 패널" width="2880" height="1800" data-path="images/docs/developers/extends/apps/document-generator/06b-front-component.png" />
</Frame>

생성된 모든 문서는 작성자로 여러분의 앱을 기록합니다:

<Frame caption="Document Generator가 생성자로 표시되고, 상태는 Generated입니다.">
  <img src="https://mintcdn.com/twenty/sqJBeTZq-W-RDBPU/images/docs/developers/extends/apps/document-generator/05-document-record.png?fit=max&auto=format&n=sqJBeTZq-W-RDBPU&q=85&s=735261a59834a51277284303a9d6f845" alt="생성된 문서 레코드" width="2880" height="1800" data-path="images/docs/developers/extends/apps/document-generator/05-document-record.png" />
</Frame>

## 레코드 페이지에서 문서 미리 보기

프런트 컴포넌트는 커맨드 메뉴에만 쓰이는 것이 아닙니다. **레코드 페이지의 탭**으로 마운트할 수도 있습니다. 문서 레코드에 *Preview* 탭을 추가하여 Markdown 본문을 정돈된 인쇄용 페이지로 렌더링해 봅시다.

컴포넌트는 실행 컨텍스트에서 현재 레코드 ID 를 읽어 문서를 로드하고 렌더링합니다. 프런트 컴포넌트는 허용된 HTML 태그 화이트리스트만 사용할 수 있는 **샌드박스**에서 실행됩니다. 원시 HTML 주입(`dangerouslySetInnerHTML`)과 `\<style>`은 차단되므로, 작은 [`Markdown`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/utils/markdown-to-react.tsx) 헬퍼를 통해 Markdown을 인라인 스타일이 적용된 React 엘리먼트로 렌더링합니다.

```tsx filename="src/front-components/document-viewer.front-component.tsx" theme={null}
import { CoreApiClient } from 'twenty-client-sdk/core';
import { defineFrontComponent } from 'twenty-sdk/define';
import { useFrontComponentExecutionContext } from 'twenty-sdk/front-component';
import { Markdown } from 'src/utils/markdown-to-react';

const DocumentViewer = () => {
  const recordId = useFrontComponentExecutionContext((c) => c.recordId ?? null);
  // ...load { content, file } for recordId, then derive the links:
  const pdfUrl = document.file?.[0]?.url;
  const webUrl = `${process.env.TWENTY_API_URL ?? ''}/s/documents/view?id=${recordId}`;

  // Render the template body, plus quick links to the web page and the PDF.
  // Links open in a new tab so they don't navigate the embedded component.
  return (
    <div style={styles.scroll}>
      <div style={styles.actions}>
        <a style={styles.actionLink} href={webUrl} target="_blank" rel="noopener noreferrer">
          Open web page
        </a>
        {pdfUrl ? (
          <a style={styles.actionLink} href={pdfUrl} target="_blank" rel="noopener noreferrer">
            Download PDF
          </a>
        ) : null}
      </div>
      <div style={styles.paper}>
        <div style={styles.body}>
          <Markdown content={document.content} />
        </div>
      </div>
    </div>
  );
};

export default defineFrontComponent({
  universalIdentifier: DOCUMENT_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
  name: 'document-viewer',
  component: DocumentViewer,
});
```

[페이지 레이아웃](/l/ko/developers/extend/apps/layout/page-layouts)으로 이를 마운트합니다. `RECORD_PAGE` 레이아웃은 객체의 레코드 뷰에 탭을 추가합니다. `CANVAS` 탭의 `FRONT_COMPONENT` 위젯이 해당 컴포넌트를 호스팅합니다:

```ts filename="src/page-layouts/document-record.page-layout.ts" theme={null}
import { definePageLayout, PageLayoutTabLayoutMode } from 'twenty-sdk/define';

export default definePageLayout({
  universalIdentifier: DOCUMENT_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
  name: 'Document record page',
  type: 'RECORD_PAGE',
  objectUniversalIdentifier: DOCUMENT_OBJECT_UNIVERSAL_IDENTIFIER,
  tabs: [{
    universalIdentifier: DOCUMENT_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER,
    title: 'Preview',
    icon: 'IconEye',
    position: 50,
    layoutMode: PageLayoutTabLayoutMode.CANVAS,
    widgets: [{
      universalIdentifier: DOCUMENT_PAGE_LAYOUT_WIDGET_UNIVERSAL_IDENTIFIER,
      title: 'Document preview',
      type: 'FRONT_COMPONENT',
      configuration: {
        configurationType: 'FRONT_COMPONENT',
        frontComponentUniversalIdentifier: DOCUMENT_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
      },
    }],
  }],
});
```

아무 문서나 열어 보면 **Preview** 탭이 문서를 보기 좋게 렌더링하고, 공유 가능한 웹 페이지와 PDF 링크도 함께 제공합니다:

<Frame caption="Preview 탭은 인라인 스타일로 문서를 렌더링하고, 빠른 링크도 제공합니다.">
  <img src="https://mintcdn.com/twenty/sqJBeTZq-W-RDBPU/images/docs/developers/extends/apps/document-generator/09-document-viewer.png?fit=max&auto=format&n=sqJBeTZq-W-RDBPU&q=85&s=e56c0d51f60f1597b55f497c35245e7e" alt="레코드 페이지 탭 안의 문서 뷰어 프런트 컴포넌트" width="2880" height="1800" data-path="images/docs/developers/extends/apps/document-generator/09-document-viewer.png" />
</Frame>

## 리치 텍스트 에디터로 템플릿 편집하기

템플릿에는 별도의 커스텀 컴포넌트가 전혀 필요하지 않습니다. `body`가 `RICH_TEXT` 필드이기 때문에, Twenty는 이미 해당 필드에 사용할 수 있는 완전한 리치 텍스트 편집기를 제공합니다. 표준 Note 및 Task 객체에서 사용하는 것과 동일한 편집기입니다. 우리는 이 에디터를 템플릿 레코드 페이지에 노출하기만 하면 됩니다.

`EDITOR` 디스플레이 모드의 `FIELD` 위젯을 가진 탭을 추가하고, `fieldMetadataId`를 통해 `body` 필드를 가리키도록 설정하세요:

```ts filename="src/page-layouts/template-record.page-layout.ts" theme={null}
{
  universalIdentifier: TEMPLATE_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER,
  title: 'Template',
  position: 1,
  layoutMode: PageLayoutTabLayoutMode.GRID,
  widgets: [{
    universalIdentifier: TEMPLATE_PAGE_LAYOUT_WIDGET_UNIVERSAL_IDENTIFIER,
    title: 'Template',
    type: 'FIELD',
    gridPosition: { row: 0, column: 0, rowSpan: 6, columnSpan: 12 },
    configuration: {
      configurationType: 'FIELD',
      fieldMetadataId: TEMPLATE_BODY_FIELD_UNIVERSAL_IDENTIFIER,
      fieldDisplayMode: 'EDITOR',
    },
  }],
}
```

`RICH_TEXT` 필드는 편집기의 블록 JSON과 Markdown 프로젝션을 모두 저장합니다. 생성 파이프라인은 그 Markdown 프로젝션을 읽으므로, 플레이스홀더, PDF, 공유 가능한 웹 페이지가 모두 변경 없이 계속 동작합니다. 전체 코드는
[`template-record.page-layout.ts`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/page-layouts/template-record.page-layout.ts)에서 확인하세요.
이제 편집자는 제대로 된 리치 텍스트 편집기에서 템플릿을 작성할 수 있습니다:

<Frame caption="Template 탭: Twenty 의 네이티브 리치 텍스트 에디터가 body 필드에 연결되어 있습니다.">
  <img src="https://mintcdn.com/twenty/sqJBeTZq-W-RDBPU/images/docs/developers/extends/apps/document-generator/10-template-editor.png?fit=max&auto=format&n=sqJBeTZq-W-RDBPU&q=85&s=4e630b22cb2fbfa57423cb42ad169da5" alt="네이티브 리치 텍스트 에디터 탭이 있는 템플릿 레코드" width="2880" height="1800" data-path="images/docs/developers/extends/apps/document-generator/10-template-editor.png" />
</Frame>

**이 단계를 마치면:** 문서는 보기 좋게 미리 볼 수 있고, 템플릿은 앱 내에서 편집할 수 있습니다. 다음으로, AI 에이전트가 채팅에서 문서를 생성하도록 만들어 봅시다.

<Card title="다음: AI 에이전트 →" icon="robot" href="/l/ko/developers/extend/apps/tutorials/document-generator/ai-agent">
  도구를 호출하는 에이전트와 스킬을 추가하세요.
</Card>
