commit ab2a3e35b2b9ae975a82e5f53dc58364c29a7a54 Author: Johngreen Date: Tue Feb 10 12:05:22 2026 +0900 feat: Phase 0-2 complete — auth, machines, equipment parts with full CRUD Multi-tenant factory inspection system (SpiFox, Enkid, Alpet): - FastAPI backend with JWT auth, PostgreSQL (asyncpg) - Next.js 16 frontend with App Router, SWR data fetching - Machines CRUD with equipment parts management - Part lifecycle tracking (hours/count/date) with counters - Partial unique index for soft-delete support - 24 pytest tests passing, E2E verified Co-Authored-By: Claude Opus 4 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..151d14a --- /dev/null +++ b/.env.example @@ -0,0 +1,20 @@ +# FactoryOps v2 환경 변수 + +# PostgreSQL 연결 (필수) +DATABASE_URL=postgresql+asyncpg://factoryops:factoryops@localhost:5432/factoryops_v2 + +# 테스트 DB +TEST_DATABASE_URL=postgresql+asyncpg://factoryops:factoryops@localhost:5432/factoryops_v2_test + +# SQL 쿼리 로깅 (디버깅용) +# SQL_ECHO=true + +# JWT 시크릿 키 (프로덕션에서 반드시 변경) +JWT_SECRET_KEY=your-super-secret-key-change-in-production + +# 서버 설정 +HOST=0.0.0.0 +PORT=8000 + +# 프론트엔드 API URL +NEXT_PUBLIC_API_URL=http://localhost:8000 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0768889 --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +# Python +__pycache__/ +*.py[cod] +venv/ +.venv/ +*.egg-info/ +dist/ +build/ + +# Environment +.env + +# Node +node_modules/ +.next/ +dashboard/.next/ +dashboard/node_modules/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Testing +.pytest_cache/ +htmlcov/ +.coverage + +# Alembic +alembic/versions/__pycache__/ + +# Planning (keep tracked) +# planning/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..4a3b64c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,86 @@ +# FactoryOps v2 개발 가이드 + +## IMPORTANT: 스킬 우선 확인 + +**YOU MUST** 서버 실행, 테스트, 커밋, 빌드 등 반복 작업 수행 전 반드시 아래 스킬을 먼저 확인하세요. +스킬에 정의된 방법을 따르지 않고 임의로 명령어를 실행하지 마세요. + +### 사용 가능한 스킬 + +| 스킬 | 설명 | 사용 시점 | +|------|------|----------| +| `/serve` | 개발 서버 시작 (Podman) | 서버 실행 필요 시 | +| `/test` | pytest 테스트 실행 | 테스트 실행 시 | +| `/commit` | Git 커밋 생성 | 코드 변경 후 커밋 시 | +| `/review` | 코드 리뷰 | 변경사항 검토 시 | +| `/ui-test` | 브라우저 UI 테스트 (Playwright MCP) | 코드 작성/수정 후 | +| `/frontend-design` | 프론트엔드 디자인 | UI 개발 시 | + +### 사용자 선호사항 +- **커밋 후 항상 푸시**: `/commit` 완료 후 자동으로 `git push` 실행 + +--- + +## 기술 스택 +- **Backend**: FastAPI (Python 3.11+) +- **Frontend**: Next.js 16 (App Router, React 19) +- **Database**: PostgreSQL 전용 (asyncpg + SQLAlchemy async) — SQLite 미사용 +- **Data Fetching**: SWR 2.x +- **Styling**: 커스텀 CSS (Material Design 3 스타일) +- **Auth**: JWT (HS256, 24h 만료) + bcrypt + +## 프로젝트 구조 +``` +factoryOps/ +├── main.py # FastAPI 앱 엔트리포인트 (60~80줄) +├── requirements.txt # Python 의존성 +├── alembic.ini # DB 마이그레이션 설정 +├── alembic/ # 마이그레이션 파일 +├── src/ +│ ├── auth/ # 인증 시스템 (JWT) +│ ├── database/ # DB 설정 + 모델 (PostgreSQL async) +│ ├── tenant/ # 멀티테넌트 관리 +│ ├── api/ # API 라우터 +│ └── services/ # 비즈니스 로직 +├── tests/ # pytest 테스트 +├── dashboard/ # Next.js 프론트엔드 +│ ├── app/ # App Router 페이지 +│ ├── components/ # 공유 컴포넌트 +│ └── lib/ # API, 훅, 유틸리티 +├── planning/ # 작업 메모리 +└── scripts/ # 유틸리티 스크립트 +``` + +## 핵심 설계 원칙 + +> **"TYPE 필드로 분기하고, 테넌트 ID로는 절대 분기하지 않는다."** + +- 업체별 차이는 `lifecycle_type`, `data_type`, `inspection_mode`, `alarm_type` 등 엔티티 TYPE 필드에서 결정 +- 코어 앱에 `if tenant == "spifox"` 같은 분기 = **0개** +- 3개 고객사: 스피폭스(프레스), 엔키드(다이캐스팅), 알펫(라미네이팅) + +## 데이터베이스 +- PostgreSQL 전용 (asyncpg 드라이버) +- JSONB, UUID, TIMESTAMPTZ 등 PostgreSQL 네이티브 타입 사용 +- 마이그레이션: Alembic +- 테스트: PostgreSQL 테스트 DB (트랜잭션 롤백 격리) + +## Vercel React Best Practices 적용 + +### 적용된 패턴 +- `server-parallel-fetching` - 서버 컴포넌트 병렬 페칭 +- `bundle-dynamic-imports` - 클라이언트 컴포넌트 동적 로드 +- `rendering-hoist-jsx` - 정적 객체 모듈 레벨 호이스팅 +- `rerender-memo` - 메모이즈된 컴포넌트 +- `client-swr-dedup` - SWR 자동 요청 중복 제거 + +## Planning with Files (작업 메모리 시스템) + +복잡한 작업 시 `planning/` 디렉토리 파일들을 작업 메모리로 활용합니다. + +``` +planning/ +├── task_plan.md # 작업 단계별 체크박스 관리 +├── findings.md # 발견한 정보, 코드 참조, 결정 사항 +└── progress.md # 시도 결과, 에러 기록, 다음 단계 +``` diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..2c24fc4 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,147 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = %(here)s/alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library. +# Any required deps can installed by adding `alembic[tz]` to the pip requirements +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to /versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +path_separator = os + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# database URL. This is consumed by the user-maintained env.py script only. +# other means of configuring database URLs may be customized within the env.py +# file. +sqlalchemy.url = postgresql+asyncpg://factoryops:factoryops@localhost:5432/factoryops_v2 + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module +# hooks = ruff +# ruff.type = module +# ruff.module = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Alternatively, use the exec runner to execute a binary found on your PATH +# hooks = ruff +# ruff.type = exec +# ruff.executable = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/alembic/README b/alembic/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..5142784 --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,64 @@ +import asyncio +import os +from logging.config import fileConfig + +from sqlalchemy import pool +from sqlalchemy.ext.asyncio import async_engine_from_config + +from alembic import context + +config = context.config + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +db_url = os.getenv("DATABASE_URL") +if db_url: + config.set_main_option("sqlalchemy.url", db_url) + +from src.database.config import Base +from src.database.models import User, Tenant, Machine # noqa: F401 + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection): + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations() -> None: + connectable = async_engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + + await connectable.dispose() + + +def run_migrations_online() -> None: + asyncio.run(run_async_migrations()) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..1101630 --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/alembic/versions/247755b06e67_add_equipment_parts_counters_.py b/alembic/versions/247755b06e67_add_equipment_parts_counters_.py new file mode 100644 index 0000000..5011efa --- /dev/null +++ b/alembic/versions/247755b06e67_add_equipment_parts_counters_.py @@ -0,0 +1,92 @@ +"""add_equipment_parts_counters_replacement_logs + +Revision ID: 247755b06e67 +Revises: f39c75bd9514 +Create Date: 2026-02-10 11:29:35.848537 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = '247755b06e67' +down_revision: Union[str, Sequence[str], None] = 'f39c75bd9514' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('equipment_parts', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('tenant_id', sa.String(length=50), nullable=False), + sa.Column('machine_id', sa.UUID(), nullable=False), + sa.Column('name', sa.String(length=100), nullable=False), + sa.Column('part_number', sa.String(length=50), nullable=True), + sa.Column('category', sa.String(length=50), nullable=True), + sa.Column('lifecycle_type', sa.String(length=20), nullable=False), + sa.Column('lifecycle_limit', sa.Float(), nullable=False), + sa.Column('alarm_threshold', sa.Float(), nullable=True), + sa.Column('counter_source', sa.String(length=20), nullable=True), + sa.Column('installed_at', postgresql.TIMESTAMP(timezone=True), nullable=False), + sa.Column('is_active', sa.Boolean(), nullable=True), + sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), nullable=True), + sa.Column('updated_at', postgresql.TIMESTAMP(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['machine_id'], ['machines.id'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('tenant_id', 'machine_id', 'name', name='uq_equipment_part_tenant_machine_name') + ) + op.create_index('ix_equipment_parts_tenant_id', 'equipment_parts', ['tenant_id'], unique=False) + op.create_index('ix_equipment_parts_tenant_machine', 'equipment_parts', ['tenant_id', 'machine_id'], unique=False) + op.create_table('part_counters', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('tenant_id', sa.String(length=50), nullable=False), + sa.Column('equipment_part_id', sa.UUID(), nullable=False), + sa.Column('current_value', sa.Float(), nullable=True), + sa.Column('lifecycle_pct', sa.Float(), nullable=True), + sa.Column('last_reset_at', postgresql.TIMESTAMP(timezone=True), nullable=False), + sa.Column('last_updated_at', postgresql.TIMESTAMP(timezone=True), nullable=False), + sa.Column('version', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['equipment_part_id'], ['equipment_parts.id'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('equipment_part_id') + ) + op.create_index('ix_part_counters_tenant_id', 'part_counters', ['tenant_id'], unique=False) + op.create_table('part_replacement_logs', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('tenant_id', sa.String(length=50), nullable=False), + sa.Column('equipment_part_id', sa.UUID(), nullable=False), + sa.Column('replaced_by', sa.UUID(), nullable=True), + sa.Column('replaced_at', postgresql.TIMESTAMP(timezone=True), nullable=False), + sa.Column('counter_at_replacement', sa.Float(), nullable=False), + sa.Column('lifecycle_pct_at_replacement', sa.Float(), nullable=False), + sa.Column('reason', sa.String(length=200), nullable=True), + sa.Column('notes', sa.Text(), nullable=True), + sa.ForeignKeyConstraint(['equipment_part_id'], ['equipment_parts.id'], ), + sa.ForeignKeyConstraint(['replaced_by'], ['users.id'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('ix_part_replacement_tenant_date', 'part_replacement_logs', ['tenant_id', 'replaced_at'], unique=False) + op.create_index('ix_part_replacement_tenant_part', 'part_replacement_logs', ['tenant_id', 'equipment_part_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index('ix_part_replacement_tenant_part', table_name='part_replacement_logs') + op.drop_index('ix_part_replacement_tenant_date', table_name='part_replacement_logs') + op.drop_table('part_replacement_logs') + op.drop_index('ix_part_counters_tenant_id', table_name='part_counters') + op.drop_table('part_counters') + op.drop_index('ix_equipment_parts_tenant_machine', table_name='equipment_parts') + op.drop_index('ix_equipment_parts_tenant_id', table_name='equipment_parts') + op.drop_table('equipment_parts') + # ### end Alembic commands ### diff --git a/alembic/versions/a3b1c2d3e4f5_fix_unique_constraint_partial_index.py b/alembic/versions/a3b1c2d3e4f5_fix_unique_constraint_partial_index.py new file mode 100644 index 0000000..d454f00 --- /dev/null +++ b/alembic/versions/a3b1c2d3e4f5_fix_unique_constraint_partial_index.py @@ -0,0 +1,42 @@ +"""fix_unique_constraint_to_partial_index_active_only + +Revision ID: a3b1c2d3e4f5 +Revises: 247755b06e67 +Create Date: 2026-02-10 22:40:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op + +revision: str = "a3b1c2d3e4f5" +down_revision: Union[str, None] = "247755b06e67" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Drop the old full unique constraint + op.drop_constraint( + "uq_equipment_part_tenant_machine_name", + "equipment_parts", + type_="unique", + ) + # Create partial unique index (active records only) + op.execute( + """ + CREATE UNIQUE INDEX uq_equipment_part_active_tenant_machine_name + ON equipment_parts (tenant_id, machine_id, name) + WHERE is_active = true + """ + ) + + +def downgrade() -> None: + op.execute("DROP INDEX IF EXISTS uq_equipment_part_active_tenant_machine_name") + op.create_unique_constraint( + "uq_equipment_part_tenant_machine_name", + "equipment_parts", + ["tenant_id", "machine_id", "name"], + ) diff --git a/alembic/versions/f39c75bd9514_initial_tables_users_tenants_machines.py b/alembic/versions/f39c75bd9514_initial_tables_users_tenants_machines.py new file mode 100644 index 0000000..90c1cde --- /dev/null +++ b/alembic/versions/f39c75bd9514_initial_tables_users_tenants_machines.py @@ -0,0 +1,95 @@ +"""initial_tables_users_tenants_machines + +Revision ID: f39c75bd9514 +Revises: +Create Date: 2026-02-10 10:58:25.665240 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import UUID, JSONB, TIMESTAMP + + +# revision identifiers, used by Alembic. +revision: str = "f39c75bd9514" +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "tenants", + sa.Column("id", sa.String(50), primary_key=True), + sa.Column("name", sa.String(100), nullable=False), + sa.Column("industry_type", sa.String(50), server_default="general"), + sa.Column("is_active", sa.Boolean, server_default="true"), + sa.Column("enabled_modules", JSONB, nullable=True), + sa.Column("workflow_config", JSONB, nullable=True), + sa.Column( + "created_at", + TIMESTAMP(timezone=True), + server_default=sa.text("now()"), + ), + ) + + op.create_table( + "users", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column("email", sa.String(255), unique=True, nullable=False), + sa.Column("password_hash", sa.String(255), nullable=False), + sa.Column("name", sa.String(100), nullable=False), + sa.Column("role", sa.String(20), nullable=False), + sa.Column( + "tenant_id", + sa.String(50), + sa.ForeignKey("tenants.id"), + nullable=True, + ), + sa.Column("is_active", sa.Boolean, server_default="true"), + sa.Column( + "created_at", + TIMESTAMP(timezone=True), + server_default=sa.text("now()"), + ), + sa.Column( + "updated_at", + TIMESTAMP(timezone=True), + server_default=sa.text("now()"), + ), + ) + + op.create_table( + "machines", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column( + "tenant_id", + sa.String(50), + sa.ForeignKey("tenants.id"), + nullable=False, + ), + sa.Column("name", sa.String(100), nullable=False), + sa.Column("equipment_code", sa.String(50), server_default=""), + sa.Column("model", sa.String(100), nullable=True), + sa.Column( + "created_at", + TIMESTAMP(timezone=True), + server_default=sa.text("now()"), + ), + sa.Column( + "updated_at", + TIMESTAMP(timezone=True), + server_default=sa.text("now()"), + ), + ) + op.create_index("ix_machines_tenant_id", "machines", ["tenant_id"]) + + +def downgrade() -> None: + op.drop_index("ix_machines_tenant_id", table_name="machines") + op.drop_table("machines") + op.drop_table("users") + op.drop_table("tenants") diff --git a/dashboard/.gitignore b/dashboard/.gitignore new file mode 100644 index 0000000..5ef6a52 --- /dev/null +++ b/dashboard/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/dashboard/README.md b/dashboard/README.md new file mode 100644 index 0000000..e215bc4 --- /dev/null +++ b/dashboard/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/dashboard/app/[tenant]/layout.tsx b/dashboard/app/[tenant]/layout.tsx new file mode 100644 index 0000000..061076c --- /dev/null +++ b/dashboard/app/[tenant]/layout.tsx @@ -0,0 +1,20 @@ +'use client'; + +import { useParams } from 'next/navigation'; +import { TopNav } from '@/components/TopNav'; +import { useTenants } from '@/lib/hooks'; + +export default function TenantLayout({ children }: { children: React.ReactNode }) { + const params = useParams(); + const tenantId = params?.tenant as string; + const { tenants } = useTenants(); + + const tenantName = tenants.find((t) => t.id === tenantId)?.name; + + return ( +
+ +
{children}
+
+ ); +} diff --git a/dashboard/app/[tenant]/machines/[id]/page.tsx b/dashboard/app/[tenant]/machines/[id]/page.tsx new file mode 100644 index 0000000..123f71c --- /dev/null +++ b/dashboard/app/[tenant]/machines/[id]/page.tsx @@ -0,0 +1,369 @@ +'use client'; + +import { useState, useCallback } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import { useMachine, useEquipmentParts } from '@/lib/hooks'; +import { useToast } from '@/lib/toast-context'; +import { api } from '@/lib/api'; +import type { EquipmentPart } from '@/lib/types'; + +const LIFECYCLE_TYPES = [ + { value: 'hours', label: '시간 (Hours)' }, + { value: 'count', label: '횟수 (Count)' }, + { value: 'date', label: '날짜 (Date)' }, +]; + +const COUNTER_SOURCES = [ + { value: 'auto_plc', label: 'PLC 자동' }, + { value: 'auto_time', label: '시간 자동' }, + { value: 'manual', label: '수동 입력' }, +]; + +interface PartForm { + name: string; + part_number: string; + category: string; + lifecycle_type: string; + lifecycle_limit: string; + alarm_threshold: string; + counter_source: string; + installed_at: string; +} + +const INITIAL_PART_FORM: PartForm = { + name: '', + part_number: '', + category: '', + lifecycle_type: 'hours', + lifecycle_limit: '', + alarm_threshold: '80', + counter_source: 'manual', + installed_at: '', +}; + +export default function MachineDetailPage() { + const params = useParams(); + const router = useRouter(); + const tenantId = params?.tenant as string; + const machineId = params?.id as string; + + const { machine, isLoading: machineLoading, mutate: mutateMachine } = useMachine(tenantId, machineId); + const { parts, isLoading: partsLoading, mutate: mutateParts } = useEquipmentParts(tenantId, machineId); + const { addToast } = useToast(); + + const [showPartModal, setShowPartModal] = useState(false); + const [partForm, setPartForm] = useState(INITIAL_PART_FORM); + const [submitting, setSubmitting] = useState(false); + const [editPart, setEditPart] = useState(null); + + const openAddPart = useCallback(() => { + setEditPart(null); + setPartForm(INITIAL_PART_FORM); + setShowPartModal(true); + }, []); + + const openEditPart = useCallback((part: EquipmentPart) => { + setEditPart(part); + setPartForm({ + name: part.name, + part_number: part.part_number || '', + category: part.category || '', + lifecycle_type: part.lifecycle_type, + lifecycle_limit: String(part.lifecycle_limit), + alarm_threshold: String(part.alarm_threshold), + counter_source: part.counter_source, + installed_at: part.installed_at ? part.installed_at.split('T')[0] : '', + }); + setShowPartModal(true); + }, []); + + const closePartModal = useCallback(() => { + setShowPartModal(false); + setPartForm(INITIAL_PART_FORM); + setEditPart(null); + }, []); + + const handlePartSubmit = useCallback(async (e: React.FormEvent) => { + e.preventDefault(); + if (!partForm.name.trim() || !partForm.lifecycle_limit) return; + + setSubmitting(true); + try { + const payload = { + name: partForm.name.trim(), + part_number: partForm.part_number.trim() || null, + category: partForm.category.trim() || null, + lifecycle_type: partForm.lifecycle_type, + lifecycle_limit: parseFloat(partForm.lifecycle_limit), + alarm_threshold: parseFloat(partForm.alarm_threshold), + counter_source: partForm.counter_source, + installed_at: partForm.installed_at || null, + }; + + if (editPart) { + await api.put(`/api/${tenantId}/parts/${editPart.id}`, payload); + addToast('부품이 수정되었습니다.', 'success'); + } else { + await api.post(`/api/${tenantId}/machines/${machineId}/parts`, payload); + addToast('부품이 등록되었습니다.', 'success'); + } + mutateParts(); + mutateMachine(); + closePartModal(); + } catch { + addToast(editPart ? '부품 수정에 실패했습니다.' : '부품 등록에 실패했습니다.', 'error'); + } finally { + setSubmitting(false); + } + }, [partForm, tenantId, machineId, editPart, mutateParts, mutateMachine, closePartModal, addToast]); + + const handleDeletePart = useCallback(async (part: EquipmentPart) => { + if (!confirm(`"${part.name}" 부품을 삭제하시겠습니까?`)) return; + try { + await api.delete(`/api/${tenantId}/parts/${part.id}`); + addToast('부품이 삭제되었습니다.', 'success'); + mutateParts(); + mutateMachine(); + } catch { + addToast('부품 삭제에 실패했습니다.', 'error'); + } + }, [tenantId, mutateParts, mutateMachine, addToast]); + + const isLoading = machineLoading || partsLoading; + + if (isLoading) { + return ( +
+ progress_activity +
+ ); + } + + if (!machine) { + return ( +
+ error +

설비를 찾을 수 없습니다.

+ +
+ ); + } + + return ( +
+
+ +
+ +
+
+ precision_manufacturing +
+

{machine.name}

+ {machine.equipment_code}{machine.model ? ` · ${machine.model}` : ''} +
+
+
+ +
+
+

+ settings + 부품 목록 ({parts.length}개) +

+ +
+ + {parts.length === 0 ? ( +
+ settings +

등록된 부품이 없습니다.

+
+ ) : ( +
+ + + + + + + + + + + + + + + + {parts.map((part) => { + const pct = part.counter?.lifecycle_pct ?? 0; + const isWarning = pct >= part.alarm_threshold; + return ( + + + + + + + + + + + + ); + })} + +
부품명카테고리수명 유형한계값현재값수명 %알람 기준입력 방식
+
+ {part.name} + {part.part_number && {part.part_number}} +
+
{part.category || '-'}{LIFECYCLE_TYPES.find((t) => t.value === part.lifecycle_type)?.label || part.lifecycle_type}{part.lifecycle_limit}{part.counter?.current_value ?? 0} +
+
+ {pct.toFixed(1)}% +
+
{part.alarm_threshold}%{COUNTER_SOURCES.find((s) => s.value === part.counter_source)?.label || part.counter_source} +
+ + +
+
+
+ )} +
+ + {showPartModal && ( +
+
e.stopPropagation()}> +
+

{editPart ? '부품 수정' : '부품 추가'}

+ +
+
+
+
+ + setPartForm((p) => ({ ...p, name: e.target.value }))} + disabled={submitting} + autoFocus + /> +
+
+ + setPartForm((p) => ({ ...p, part_number: e.target.value }))} + disabled={submitting} + /> +
+
+ + setPartForm((p) => ({ ...p, category: e.target.value }))} + disabled={submitting} + /> +
+
+ + +
+
+ + setPartForm((p) => ({ ...p, lifecycle_limit: e.target.value }))} + disabled={submitting} + min="0" + step="any" + /> +
+
+ + setPartForm((p) => ({ ...p, alarm_threshold: e.target.value }))} + disabled={submitting} + min="0" + max="100" + step="any" + /> +
+
+ + +
+
+ + setPartForm((p) => ({ ...p, installed_at: e.target.value }))} + disabled={submitting} + /> +
+
+
+ + +
+
+
+
+ )} +
+ ); +} diff --git a/dashboard/app/[tenant]/page.tsx b/dashboard/app/[tenant]/page.tsx new file mode 100644 index 0000000..56d3873 --- /dev/null +++ b/dashboard/app/[tenant]/page.tsx @@ -0,0 +1,195 @@ +'use client'; + +import { useState, useCallback } from 'react'; +import { useParams } from 'next/navigation'; +import { useMachines } from '@/lib/hooks'; +import { useToast } from '@/lib/toast-context'; +import { api } from '@/lib/api'; +import { MachineList } from '@/components/MachineList'; +import type { Machine } from '@/lib/types'; + +interface MachineForm { + name: string; + equipment_code: string; + model: string; +} + +const INITIAL_FORM: MachineForm = { name: '', equipment_code: '', model: '' }; + +export default function TenantDashboard() { + const params = useParams(); + const tenantId = params?.tenant as string; + const { machines, isLoading, error, mutate } = useMachines(tenantId); + const { addToast } = useToast(); + + const [showModal, setShowModal] = useState(false); + const [form, setForm] = useState(INITIAL_FORM); + const [submitting, setSubmitting] = useState(false); + const [editTarget, setEditTarget] = useState(null); + + const openCreate = useCallback(() => { + setEditTarget(null); + setForm(INITIAL_FORM); + setShowModal(true); + }, []); + + const openEdit = useCallback((machine: Machine) => { + setEditTarget(machine); + setForm({ + name: machine.name, + equipment_code: machine.equipment_code, + model: machine.model || '', + }); + setShowModal(true); + }, []); + + const closeModal = useCallback(() => { + setShowModal(false); + setForm(INITIAL_FORM); + setEditTarget(null); + }, []); + + const handleSubmit = useCallback(async (e: React.FormEvent) => { + e.preventDefault(); + if (!form.name.trim() || !form.equipment_code.trim()) return; + + setSubmitting(true); + try { + const payload = { + name: form.name.trim(), + equipment_code: form.equipment_code.trim(), + model: form.model.trim() || null, + }; + + if (editTarget) { + await api.put(`/api/${tenantId}/machines/${editTarget.id}`, payload); + addToast('설비가 수정되었습니다.', 'success'); + } else { + await api.post(`/api/${tenantId}/machines`, payload); + addToast('설비가 등록되었습니다.', 'success'); + } + mutate(); + closeModal(); + } catch { + addToast(editTarget ? '설비 수정에 실패했습니다.' : '설비 등록에 실패했습니다.', 'error'); + } finally { + setSubmitting(false); + } + }, [form, tenantId, editTarget, mutate, closeModal, addToast]); + + const handleDelete = useCallback(async (machine: Machine) => { + if (!confirm(`"${machine.name}" 설비를 삭제하시겠습니까?`)) return; + try { + await api.delete(`/api/${tenantId}/machines/${machine.id}`); + addToast('설비가 삭제되었습니다.', 'success'); + mutate(); + } catch { + addToast('설비 삭제에 실패했습니다. 부품이 등록된 설비는 삭제할 수 없습니다.', 'error'); + } + }, [tenantId, mutate, addToast]); + + if (error) { + return ( +
+ error +

데이터를 불러오는 데 실패했습니다.

+
+ ); + } + + return ( +
+
+

+ precision_manufacturing + 설비 관리 +

+ +
+ + {isLoading ? ( +
+ progress_activity +
+ ) : machines.length === 0 ? ( +
+ construction +

등록된 설비가 없습니다.

+ +
+ ) : ( + + )} + + {showModal && ( +
+
e.stopPropagation()}> +
+

{editTarget ? '설비 수정' : '새 설비 등록'}

+ +
+
+
+ + setForm((prev) => ({ ...prev, name: e.target.value }))} + disabled={submitting} + autoFocus + /> +
+
+ + setForm((prev) => ({ ...prev, equipment_code: e.target.value }))} + disabled={submitting} + /> +
+
+ + setForm((prev) => ({ ...prev, model: e.target.value }))} + disabled={submitting} + /> +
+
+ + +
+
+
+
+ )} +
+ ); +} diff --git a/dashboard/app/favicon.ico b/dashboard/app/favicon.ico new file mode 100644 index 0000000..718d6fe Binary files /dev/null and b/dashboard/app/favicon.ico differ diff --git a/dashboard/app/globals.css b/dashboard/app/globals.css new file mode 100644 index 0000000..c05a53e --- /dev/null +++ b/dashboard/app/globals.css @@ -0,0 +1,1073 @@ +:root { + --md-primary: #1a73e8; + --md-primary-dark: #1557b0; + --md-success: #1e8e3e; + --md-error: #d93025; + --md-warning: #f9ab00; + --md-surface: #ffffff; + --md-surface-variant: #f8f9fa; + --md-on-surface: #202124; + --md-on-surface-secondary: #5f6368; + --md-outline: #dadce0; + --md-radius: 8px; + --md-radius-lg: 12px; +} + +* { + box-sizing: border-box; + padding: 0; + margin: 0; +} + +html, body { + max-width: 100vw; + overflow-x: hidden; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + color: var(--md-on-surface); + background: var(--md-surface-variant); + -webkit-font-smoothing: antialiased; +} + +a { + color: inherit; + text-decoration: none; +} + +.material-symbols-outlined { + font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +.spinning { + animation: spin 1s linear infinite; +} + +.loading { + display: flex; + align-items: center; + justify-content: center; + padding: 40px; + color: var(--md-primary); +} + +.loading .material-symbols-outlined { + font-size: 36px; +} + +.auth-loading { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + background: var(--md-surface-variant); +} + +.auth-loading .material-symbols-outlined { + font-size: 48px; + color: var(--md-primary); +} + +.card { + background: var(--md-surface); + border-radius: var(--md-radius-lg); + border: 1px solid var(--md-outline); + overflow: hidden; +} + +.card-header { + display: flex; + align-items: center; + gap: 8px; + padding: 16px 20px; + border-bottom: 1px solid var(--md-outline); + font-size: 14px; + font-weight: 500; + color: var(--md-on-surface); +} + +.card-header .material-symbols-outlined { + font-size: 20px; + color: var(--md-primary); +} + +.card-body { + padding: 20px; +} + +.btn-outline { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 8px 16px; + border: 1px solid var(--md-outline); + border-radius: 20px; + background: transparent; + color: var(--md-on-surface-secondary); + font-size: 13px; + cursor: pointer; + transition: background 0.2s; +} + +.btn-outline:hover { + background: var(--md-surface-variant); +} + +.btn-outline .material-symbols-outlined { + font-size: 16px; +} + +.empty-message { + text-align: center; + padding: 32px; + color: var(--md-on-surface-secondary); + font-size: 14px; +} + +.home-container { + max-width: 960px; + margin: 0 auto; + padding: 32px 20px; +} + +.home-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 32px; +} + +.home-title { + display: flex; + align-items: center; + gap: 12px; +} + +.home-title .material-symbols-outlined { + font-size: 32px; + color: var(--md-primary); +} + +.home-title h1 { + font-size: 24px; + font-weight: 500; +} + +.home-user { + display: flex; + align-items: center; + gap: 12px; + font-size: 13px; + color: var(--md-on-surface-secondary); +} + +.tenant-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 16px; +} + +.tenant-card { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 24px 16px; + border: 1px solid var(--md-outline); + border-radius: var(--md-radius-lg); + background: var(--md-surface); + cursor: pointer; + transition: border-color 0.2s, box-shadow 0.2s; +} + +.tenant-card:hover { + border-color: var(--md-primary); + box-shadow: 0 2px 8px rgba(26, 115, 232, 0.15); +} + +.tenant-icon { + font-size: 36px; + color: var(--md-primary); +} + +.tenant-name { + font-size: 15px; + font-weight: 500; + color: var(--md-on-surface); +} + +.tenant-id { + font-size: 12px; + color: var(--md-on-surface-secondary); +} + +.login-container { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + background: var(--md-surface-variant); + padding: 20px; +} + +.login-card { + background: var(--md-surface); + border-radius: var(--md-radius-lg); + padding: 40px; + width: 100%; + max-width: 400px; + border: 1px solid var(--md-outline); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08), 0 4px 12px rgba(0, 0, 0, 0.05); +} + +.login-header { + text-align: center; + margin-bottom: 32px; +} + +.login-icon { + font-size: 48px; + color: var(--md-primary); + margin-bottom: 12px; + display: block; +} + +.login-header h1 { + font-size: 24px; + font-weight: 500; + margin: 0 0 8px 0; +} + +.login-header p { + font-size: 14px; + color: var(--md-on-surface-secondary); + margin: 0; +} + +.login-form { + display: flex; + flex-direction: column; + gap: 20px; +} + +.login-error { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 16px; + background: #fce8e6; + border-radius: var(--md-radius); + color: var(--md-error); + font-size: 14px; +} + +.login-error .material-symbols-outlined { + font-size: 20px; +} + +.form-field { + display: flex; + flex-direction: column; + gap: 8px; +} + +.form-field label { + font-size: 14px; + font-weight: 500; + color: var(--md-on-surface-secondary); +} + +.form-field input { + padding: 14px 16px; + border: 1px solid var(--md-outline); + border-radius: var(--md-radius); + background: var(--md-surface); + color: var(--md-on-surface); + font-size: 16px; + transition: border-color 0.2s, box-shadow 0.2s; +} + +.form-field input:focus { + outline: none; + border-color: var(--md-primary); + box-shadow: 0 0 0 2px rgba(26, 115, 232, 0.2); +} + +.form-field input::placeholder { + color: #9aa0a6; +} + +.form-field input:disabled { + background: #f1f3f4; + opacity: 0.7; + cursor: not-allowed; +} + +.login-button { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 14px 24px; + background: var(--md-primary); + color: #ffffff; + border: none; + border-radius: 20px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: background 0.2s, box-shadow 0.2s; + margin-top: 8px; +} + +.login-button:hover:not(:disabled) { + background: var(--md-primary-dark); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); +} + +.login-button:disabled { + background: #a8c7fa; + cursor: not-allowed; +} + +.login-button .material-symbols-outlined { + font-size: 18px; +} + +.toast-container { + position: fixed; + bottom: 24px; + right: 24px; + z-index: 1000; + display: flex; + flex-direction: column; + gap: 8px; +} + +.toast { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + border-radius: var(--md-radius); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + min-width: 300px; + max-width: 480px; + animation: toast-in 0.3s ease; +} + +.toast-success { background: #e6f4ea; color: var(--md-success); } +.toast-error { background: #fce8e6; color: var(--md-error); } +.toast-info { background: #e8f0fe; color: var(--md-primary); } + +.toast-icon { font-size: 20px; } +.toast-message { flex: 1; font-size: 14px; } + +.toast-close { + background: none; + border: none; + cursor: pointer; + color: inherit; + opacity: 0.6; + padding: 4px; +} + +.toast-close:hover { opacity: 1; } + +.toast-exit { + animation: toast-out 0.3s ease forwards; +} + +@keyframes toast-in { + from { transform: translateX(100%); opacity: 0; } + to { transform: translateX(0); opacity: 1; } +} + +@keyframes toast-out { + from { transform: translateX(0); opacity: 1; } + to { transform: translateX(100%); opacity: 0; } +} + +/* ===== TopNav ===== */ +.topnav { + position: fixed; + top: 0; + left: 0; + right: 0; + height: 56px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 20px; + background: var(--md-surface); + border-bottom: 1px solid var(--md-outline); + z-index: 100; +} + +.topnav-left { + display: flex; + align-items: center; + gap: 12px; +} + +.topnav-brand { + display: flex; + align-items: center; + gap: 8px; + background: none; + border: none; + cursor: pointer; + padding: 4px; + border-radius: var(--md-radius); + color: var(--md-primary); + transition: background 0.2s; +} + +.topnav-brand:hover { + background: var(--md-surface-variant); +} + +.topnav-brand .material-symbols-outlined { + font-size: 24px; +} + +.topnav-app-name { + font-size: 16px; + font-weight: 600; + color: var(--md-on-surface); +} + +.topnav-divider { + width: 1px; + height: 24px; + background: var(--md-outline); +} + +.topnav-tenant { + font-size: 14px; + font-weight: 500; + color: var(--md-on-surface-secondary); +} + +.topnav-center { + display: flex; + align-items: center; + gap: 4px; +} + +.topnav-item { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 14px; + background: none; + border: none; + border-radius: 20px; + cursor: pointer; + font-size: 13px; + font-weight: 500; + color: var(--md-on-surface-secondary); + transition: background 0.2s, color 0.2s; +} + +.topnav-item:hover { + background: var(--md-surface-variant); + color: var(--md-on-surface); +} + +.topnav-item.active { + background: #e8f0fe; + color: var(--md-primary); +} + +.topnav-item .material-symbols-outlined { + font-size: 18px; +} + +.topnav-right { + display: flex; + align-items: center; + gap: 12px; +} + +.topnav-user { + font-size: 13px; + color: var(--md-on-surface-secondary); +} + +/* ===== Tenant Layout ===== */ +.tenant-layout { + min-height: 100vh; +} + +.tenant-main { + padding-top: 56px; +} + +/* ===== Page Common ===== */ +.page-container { + max-width: 1200px; + margin: 0 auto; + padding: 24px 20px; +} + +.page-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 24px; +} + +.page-title { + display: flex; + align-items: center; + gap: 10px; + font-size: 20px; + font-weight: 500; +} + +.page-title .material-symbols-outlined { + font-size: 28px; + color: var(--md-primary); +} + +.page-error { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + padding: 80px 20px; + color: var(--md-error); +} + +.page-error .material-symbols-outlined { + font-size: 48px; +} + +.page-error p { + font-size: 16px; + color: var(--md-on-surface-secondary); +} + +.page-breadcrumb { + margin-bottom: 16px; +} + +/* ===== Buttons ===== */ +.btn-primary { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 10px 20px; + background: var(--md-primary); + color: #ffffff; + border: none; + border-radius: 20px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: background 0.2s, box-shadow 0.2s; +} + +.btn-primary:hover:not(:disabled) { + background: var(--md-primary-dark); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); +} + +.btn-primary:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.btn-primary .material-symbols-outlined { + font-size: 18px; +} + +.btn-sm { + padding: 6px 12px; + font-size: 12px; +} + +.btn-sm .material-symbols-outlined { + font-size: 16px; +} + +.btn-text { + display: inline-flex; + align-items: center; + gap: 4px; + background: none; + border: none; + color: var(--md-on-surface-secondary); + font-size: 13px; + cursor: pointer; + padding: 6px 8px; + border-radius: var(--md-radius); + transition: background 0.2s, color 0.2s; +} + +.btn-text:hover { + background: var(--md-surface-variant); + color: var(--md-on-surface); +} + +.btn-text .material-symbols-outlined { + font-size: 18px; +} + +.btn-icon { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + background: none; + border: none; + border-radius: 50%; + cursor: pointer; + color: var(--md-on-surface-secondary); + transition: background 0.2s, color 0.2s; +} + +.btn-icon:hover { + background: var(--md-surface-variant); + color: var(--md-on-surface); +} + +.btn-icon .material-symbols-outlined { + font-size: 18px; +} + +.btn-icon-danger:hover { + background: #fce8e6; + color: var(--md-error); +} + +/* ===== Empty State ===== */ +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + padding: 60px 20px; + color: var(--md-on-surface-secondary); +} + +.empty-state .material-symbols-outlined { + font-size: 48px; + opacity: 0.4; +} + +.empty-state p { + font-size: 15px; +} + +/* ===== Machine Grid ===== */ +.machine-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 16px; +} + +.machine-card { + background: var(--md-surface); + border: 1px solid var(--md-outline); + border-radius: var(--md-radius-lg); + overflow: hidden; + transition: border-color 0.2s, box-shadow 0.2s; +} + +.machine-card:hover { + border-color: var(--md-primary); + box-shadow: 0 2px 8px rgba(26, 115, 232, 0.12); +} + +.machine-card-body { + display: flex; + flex-direction: column; + gap: 12px; + padding: 20px; + cursor: pointer; +} + +.machine-card-icon { + color: var(--md-primary); +} + +.machine-card-icon .material-symbols-outlined { + font-size: 32px; +} + +.machine-card-info { + display: flex; + flex-direction: column; + gap: 4px; +} + +.machine-card-name { + font-size: 16px; + font-weight: 500; +} + +.machine-card-code { + font-size: 13px; + color: var(--md-on-surface-secondary); + font-family: 'SF Mono', 'Menlo', monospace; +} + +.machine-card-model { + font-size: 12px; + color: var(--md-on-surface-secondary); +} + +.machine-card-meta { + display: flex; + align-items: center; + gap: 12px; + padding-top: 8px; + border-top: 1px solid var(--md-outline); +} + +.machine-card-parts { + display: flex; + align-items: center; + gap: 4px; + font-size: 12px; + color: var(--md-on-surface-secondary); +} + +.machine-card-parts .material-symbols-outlined { + font-size: 16px; +} + +.machine-card-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 4px; + padding: 8px 12px; + border-top: 1px solid var(--md-outline); + background: var(--md-surface-variant); +} + +/* ===== Detail Page ===== */ +.detail-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 24px; + padding: 20px; + background: var(--md-surface); + border: 1px solid var(--md-outline); + border-radius: var(--md-radius-lg); +} + +.detail-header-left { + display: flex; + align-items: center; + gap: 16px; +} + +.detail-icon { + font-size: 36px; + color: var(--md-primary); +} + +.detail-title { + font-size: 20px; + font-weight: 500; + margin: 0; +} + +.detail-subtitle { + font-size: 14px; + color: var(--md-on-surface-secondary); +} + +/* ===== Section ===== */ +.section { + background: var(--md-surface); + border: 1px solid var(--md-outline); + border-radius: var(--md-radius-lg); + overflow: hidden; +} + +.section-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + border-bottom: 1px solid var(--md-outline); +} + +.section-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 15px; + font-weight: 500; + margin: 0; +} + +.section-title .material-symbols-outlined { + font-size: 20px; + color: var(--md-primary); +} + +/* ===== Part Table ===== */ +.part-table-wrap { + overflow-x: auto; +} + +.part-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} + +.part-table th { + text-align: left; + padding: 10px 16px; + font-weight: 500; + color: var(--md-on-surface-secondary); + background: var(--md-surface-variant); + border-bottom: 1px solid var(--md-outline); + white-space: nowrap; +} + +.part-table td { + padding: 12px 16px; + border-bottom: 1px solid var(--md-outline); + vertical-align: middle; +} + +.part-table tbody tr:hover { + background: #f8f9fa; +} + +.part-table tbody tr:last-child td { + border-bottom: none; +} + +.part-row-warning { + background: #fff8e1 !important; +} + +.part-name-cell { + display: flex; + flex-direction: column; + gap: 2px; +} + +.part-name-cell strong { + font-weight: 500; +} + +.part-number { + font-size: 11px; + color: var(--md-on-surface-secondary); + font-family: 'SF Mono', 'Menlo', monospace; +} + +.part-actions { + display: flex; + align-items: center; + gap: 2px; +} + +/* ===== Percentage Bar ===== */ +.pct-bar-wrap { + display: flex; + align-items: center; + gap: 8px; + min-width: 100px; +} + +.pct-bar { + height: 6px; + background: var(--md-primary); + border-radius: 3px; + transition: width 0.3s; +} + +.pct-bar-warning { + background: var(--md-warning); +} + +.pct-label { + font-size: 12px; + font-weight: 500; + white-space: nowrap; +} + +/* ===== Modal ===== */ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.4); + display: flex; + align-items: center; + justify-content: center; + z-index: 200; + padding: 20px; +} + +.modal-content { + background: var(--md-surface); + border-radius: var(--md-radius-lg); + width: 100%; + max-width: 480px; + max-height: 90vh; + overflow-y: auto; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2); +} + +.modal-lg { + max-width: 640px; +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 20px 24px 0; +} + +.modal-header h3 { + font-size: 18px; + font-weight: 500; + margin: 0; +} + +.modal-close { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + background: none; + border: none; + border-radius: 50%; + cursor: pointer; + color: var(--md-on-surface-secondary); + transition: background 0.2s; +} + +.modal-close:hover { + background: var(--md-surface-variant); +} + +.modal-content form { + padding: 20px 24px 24px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.modal-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + padding-top: 8px; +} + +/* ===== Form Grid ===== */ +.form-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; +} + +.form-field select { + padding: 14px 16px; + border: 1px solid var(--md-outline); + border-radius: var(--md-radius); + background: var(--md-surface); + color: var(--md-on-surface); + font-size: 16px; + transition: border-color 0.2s, box-shadow 0.2s; + width: 100%; +} + +.form-field select:focus { + outline: none; + border-color: var(--md-primary); + box-shadow: 0 0 0 2px rgba(26, 115, 232, 0.2); +} + +.form-field select:disabled { + background: #f1f3f4; + opacity: 0.7; + cursor: not-allowed; +} + +.form-field input[type="number"] { + -moz-appearance: textfield; +} + +.form-field input[type="number"]::-webkit-outer-spin-button, +.form-field input[type="number"]::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} + +/* ===== Responsive ===== */ +@media (max-width: 768px) { + .topnav-center { + display: none; + } + + .topnav-app-name { + display: none; + } + + .machine-grid { + grid-template-columns: 1fr; + } + + .form-grid { + grid-template-columns: 1fr; + } + + .part-table { + font-size: 12px; + } + + .part-table th, + .part-table td { + padding: 8px 10px; + } +} + +@media (max-width: 480px) { + .login-card { + padding: 32px 24px; + border-radius: 0; + border: none; + box-shadow: none; + max-width: none; + } + + .login-container { + padding: 0; + background: var(--md-surface); + } + + .home-header { + flex-direction: column; + gap: 16px; + align-items: flex-start; + } + + .tenant-grid { + grid-template-columns: 1fr; + } +} diff --git a/dashboard/app/layout.tsx b/dashboard/app/layout.tsx new file mode 100644 index 0000000..798f694 --- /dev/null +++ b/dashboard/app/layout.tsx @@ -0,0 +1,28 @@ +import type { Metadata } from 'next'; +import './globals.css'; +import { Providers } from './providers'; + +export const metadata: Metadata = { + title: 'FactoryOps v2', + description: '설비검사/품질검사 시스템', +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + + + + {children} + + + ); +} diff --git a/dashboard/app/login/page.tsx b/dashboard/app/login/page.tsx new file mode 100644 index 0000000..e262a85 --- /dev/null +++ b/dashboard/app/login/page.tsx @@ -0,0 +1,93 @@ +'use client'; + +import { useState, FormEvent } from 'react'; +import { useRouter } from 'next/navigation'; +import { useAuth } from '@/lib/auth-context'; + +export default function LoginPage() { + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const { login } = useAuth(); + const router = useRouter(); + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + setError(''); + setIsLoading(true); + + const result = await login(email, password); + + if (result.success) { + router.push('/'); + } else { + setError(result.error || '로그인에 실패했습니다.'); + } + + setIsLoading(false); + }; + + return ( +
+
+
+ precision_manufacturing +

FactoryOps

+

설비검사 / 품질검사 시스템

+
+ +
+ {error && ( +
+ error + {error} +
+ )} + +
+ + setEmail(e.target.value)} + placeholder="admin@example.com" + required + autoComplete="email" + disabled={isLoading} + /> +
+ +
+ + setPassword(e.target.value)} + placeholder="비밀번호 입력" + required + autoComplete="current-password" + disabled={isLoading} + /> +
+ + +
+
+
+ ); +} diff --git a/dashboard/app/page.module.css b/dashboard/app/page.module.css new file mode 100644 index 0000000..59dea42 --- /dev/null +++ b/dashboard/app/page.module.css @@ -0,0 +1,141 @@ +.page { + --background: #fafafa; + --foreground: #fff; + + --text-primary: #000; + --text-secondary: #666; + + --button-primary-hover: #383838; + --button-secondary-hover: #f2f2f2; + --button-secondary-border: #ebebeb; + + display: flex; + min-height: 100vh; + align-items: center; + justify-content: center; + font-family: var(--font-geist-sans); + background-color: var(--background); +} + +.main { + display: flex; + min-height: 100vh; + width: 100%; + max-width: 800px; + flex-direction: column; + align-items: flex-start; + justify-content: space-between; + background-color: var(--foreground); + padding: 120px 60px; +} + +.intro { + display: flex; + flex-direction: column; + align-items: flex-start; + text-align: left; + gap: 24px; +} + +.intro h1 { + max-width: 320px; + font-size: 40px; + font-weight: 600; + line-height: 48px; + letter-spacing: -2.4px; + text-wrap: balance; + color: var(--text-primary); +} + +.intro p { + max-width: 440px; + font-size: 18px; + line-height: 32px; + text-wrap: balance; + color: var(--text-secondary); +} + +.intro a { + font-weight: 500; + color: var(--text-primary); +} + +.ctas { + display: flex; + flex-direction: row; + width: 100%; + max-width: 440px; + gap: 16px; + font-size: 14px; +} + +.ctas a { + display: flex; + justify-content: center; + align-items: center; + height: 40px; + padding: 0 16px; + border-radius: 128px; + border: 1px solid transparent; + transition: 0.2s; + cursor: pointer; + width: fit-content; + font-weight: 500; +} + +a.primary { + background: var(--text-primary); + color: var(--background); + gap: 8px; +} + +a.secondary { + border-color: var(--button-secondary-border); +} + +/* Enable hover only on non-touch devices */ +@media (hover: hover) and (pointer: fine) { + a.primary:hover { + background: var(--button-primary-hover); + border-color: transparent; + } + + a.secondary:hover { + background: var(--button-secondary-hover); + border-color: transparent; + } +} + +@media (max-width: 600px) { + .main { + padding: 48px 24px; + } + + .intro { + gap: 16px; + } + + .intro h1 { + font-size: 32px; + line-height: 40px; + letter-spacing: -1.92px; + } +} + +@media (prefers-color-scheme: dark) { + .logo { + filter: invert(); + } + + .page { + --background: #000; + --foreground: #000; + + --text-primary: #ededed; + --text-secondary: #999; + + --button-primary-hover: #ccc; + --button-secondary-hover: #1a1a1a; + --button-secondary-border: #1a1a1a; + } +} diff --git a/dashboard/app/page.tsx b/dashboard/app/page.tsx new file mode 100644 index 0000000..44b1ad1 --- /dev/null +++ b/dashboard/app/page.tsx @@ -0,0 +1,60 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { useAuth } from '@/lib/auth-context'; +import { useTenants } from '@/lib/hooks'; +import { Card } from '@/components/Card'; + +export default function HomePage() { + const { user, logout } = useAuth(); + const { tenants, isLoading } = useTenants(); + const router = useRouter(); + + const handleTenantSelect = (tenantId: string) => { + router.push(`/${tenantId}`); + }; + + return ( +
+
+
+ precision_manufacturing +

FactoryOps v2

+
+
+ {user?.name} ({user?.role}) + +
+
+ +
+ + {isLoading ? ( +
+ progress_activity +
+ ) : tenants.length === 0 ? ( +

접근 가능한 고객사가 없습니다.

+ ) : ( +
+ {tenants.map((tenant) => ( + + ))} +
+ )} +
+
+
+ ); +} diff --git a/dashboard/app/providers.tsx b/dashboard/app/providers.tsx new file mode 100644 index 0000000..0ac2d08 --- /dev/null +++ b/dashboard/app/providers.tsx @@ -0,0 +1,18 @@ +'use client'; + +import { AuthProvider } from '@/lib/auth-context'; +import { TenantProvider } from '@/lib/tenant-context'; +import { ToastProvider } from '@/lib/toast-context'; +import { AuthGuard } from '@/components/AuthGuard'; + +export function Providers({ children }: { children: React.ReactNode }) { + return ( + + + + {children} + + + + ); +} diff --git a/dashboard/components/AuthGuard.tsx b/dashboard/components/AuthGuard.tsx new file mode 100644 index 0000000..9454c65 --- /dev/null +++ b/dashboard/components/AuthGuard.tsx @@ -0,0 +1,43 @@ +'use client'; + +import { useEffect } from 'react'; +import { useRouter, usePathname } from 'next/navigation'; +import { useAuth } from '@/lib/auth-context'; + +const PUBLIC_PATHS = ['/login']; + +export function AuthGuard({ children }: { children: React.ReactNode }) { + const { isAuthenticated, isLoading } = useAuth(); + const router = useRouter(); + const pathname = usePathname(); + + const isPublicPath = PUBLIC_PATHS.includes(pathname); + + useEffect(() => { + if (isLoading) return; + + if (!isAuthenticated && !isPublicPath) { + router.push('/login'); + } else if (isAuthenticated && isPublicPath) { + router.push('/'); + } + }, [isAuthenticated, isLoading, isPublicPath, router]); + + if (isLoading) { + return ( +
+ progress_activity +
+ ); + } + + if (!isAuthenticated && !isPublicPath) { + return null; + } + + if (isAuthenticated && isPublicPath) { + return null; + } + + return <>{children}; +} diff --git a/dashboard/components/Card.tsx b/dashboard/components/Card.tsx new file mode 100644 index 0000000..e3cfb18 --- /dev/null +++ b/dashboard/components/Card.tsx @@ -0,0 +1,24 @@ +import type { ReactNode } from 'react'; + +interface CardProps { + icon?: string; + title?: ReactNode; + headerRight?: ReactNode; + children: ReactNode; + className?: string; +} + +export function Card({ icon, title, headerRight, children, className = '' }: CardProps) { + return ( +
+ {(icon || title) && ( +
+ {icon && {icon}} + {title && {title}} + {headerRight &&
{headerRight}
} +
+ )} +
{children}
+
+ ); +} diff --git a/dashboard/components/MachineList.tsx b/dashboard/components/MachineList.tsx new file mode 100644 index 0000000..bd61452 --- /dev/null +++ b/dashboard/components/MachineList.tsx @@ -0,0 +1,61 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import type { Machine } from '@/lib/types'; + +interface MachineListProps { + machines: Machine[]; + tenantId: string; + onEdit: (machine: Machine) => void; + onDelete: (machine: Machine) => void; +} + +export function MachineList({ machines, tenantId, onEdit, onDelete }: MachineListProps) { + const router = useRouter(); + + return ( +
+ {machines.map((machine) => ( +
+
router.push(`/${tenantId}/machines/${machine.id}`)} + > +
+ precision_manufacturing +
+
+

{machine.name}

+ {machine.equipment_code} + {machine.model && ( + {machine.model} + )} +
+
+
+ settings + 부품 {machine.parts_count}개 +
+
+
+
+ + +
+
+ ))} +
+ ); +} diff --git a/dashboard/components/Toast.tsx b/dashboard/components/Toast.tsx new file mode 100644 index 0000000..6c339e5 --- /dev/null +++ b/dashboard/components/Toast.tsx @@ -0,0 +1,49 @@ +'use client'; + +import React, { useEffect, useState } from 'react'; + +export type ToastType = 'success' | 'error' | 'info'; + +export interface ToastProps { + id: string; + message: string; + type: ToastType; + duration?: number; + onClose: (id: string) => void; +} + +export const Toast: React.FC = ({ id, message, type, duration = 3000, onClose }) => { + const [isExiting, setIsExiting] = useState(false); + + useEffect(() => { + if (duration > 0) { + const timer = setTimeout(() => { + handleClose(); + }, duration); + return () => clearTimeout(timer); + } + }, [duration]); + + const handleClose = () => { + setIsExiting(true); + setTimeout(() => { + onClose(id); + }, 300); + }; + + const iconMap: Record = { + success: 'check_circle', + error: 'error', + info: 'info', + }; + + return ( +
+ {iconMap[type]} + {message} + +
+ ); +}; diff --git a/dashboard/components/TopNav.tsx b/dashboard/components/TopNav.tsx new file mode 100644 index 0000000..27e6915 --- /dev/null +++ b/dashboard/components/TopNav.tsx @@ -0,0 +1,62 @@ +'use client'; + +import { usePathname, useRouter } from 'next/navigation'; +import { useAuth } from '@/lib/auth-context'; + +interface NavItem { + label: string; + icon: string; + path: string; +} + +const NAV_ITEMS: NavItem[] = [ + { label: '대시보드', icon: 'dashboard', path: '' }, + { label: '검사 템플릿', icon: 'assignment', path: '/templates' }, + { label: '검사 실행', icon: 'fact_check', path: '/inspections' }, + { label: '알람', icon: 'notifications', path: '/alarms' }, +]; + +export function TopNav({ tenantId, tenantName }: { tenantId: string; tenantName?: string }) { + const { user, logout } = useAuth(); + const router = useRouter(); + const pathname = usePathname(); + + const isActive = (path: string) => { + const fullPath = `/${tenantId}${path}`; + if (path === '') return pathname === fullPath; + return pathname.startsWith(fullPath); + }; + + return ( + + ); +} diff --git a/dashboard/eslint.config.mjs b/dashboard/eslint.config.mjs new file mode 100644 index 0000000..05e726d --- /dev/null +++ b/dashboard/eslint.config.mjs @@ -0,0 +1,18 @@ +import { defineConfig, globalIgnores } from "eslint/config"; +import nextVitals from "eslint-config-next/core-web-vitals"; +import nextTs from "eslint-config-next/typescript"; + +const eslintConfig = defineConfig([ + ...nextVitals, + ...nextTs, + // Override default ignores of eslint-config-next. + globalIgnores([ + // Default ignores of eslint-config-next: + ".next/**", + "out/**", + "build/**", + "next-env.d.ts", + ]), +]); + +export default eslintConfig; diff --git a/dashboard/lib/api.ts b/dashboard/lib/api.ts new file mode 100644 index 0000000..dfafdf9 --- /dev/null +++ b/dashboard/lib/api.ts @@ -0,0 +1,73 @@ +import { getStoredToken } from './auth-context'; +import { getTenantFromPath } from './tenant-context'; + +const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'; + +function getHeaders(): HeadersInit { + const token = getStoredToken(); + return { + 'Content-Type': 'application/json', + ...(token ? { 'Authorization': `Bearer ${token}` } : {}), + }; +} + +export async function fetcher(url: string): Promise { + const res = await fetch(`${API_BASE_URL}${url}`, { + headers: getHeaders(), + }); + if (!res.ok) { + throw new Error('API request failed'); + } + return res.json(); +} + +export function getTenantUrl(path: string, tenantId?: string): string { + const tenant = tenantId || getTenantFromPath(); + if (!tenant) { + throw new Error('Tenant ID not found'); + } + return `/api/${tenant}${path}`; +} + +export const api = { + get: (url: string) => fetcher(url), + + post: async (url: string, data: unknown): Promise => { + const res = await fetch(`${API_BASE_URL}${url}`, { + method: 'POST', + headers: getHeaders(), + body: JSON.stringify(data), + }); + if (!res.ok) throw new Error('API request failed'); + return res.json(); + }, + + put: async (url: string, data: unknown): Promise => { + const res = await fetch(`${API_BASE_URL}${url}`, { + method: 'PUT', + headers: getHeaders(), + body: JSON.stringify(data), + }); + if (!res.ok) throw new Error('API request failed'); + return res.json(); + }, + + patch: async (url: string, data: unknown): Promise => { + const res = await fetch(`${API_BASE_URL}${url}`, { + method: 'PATCH', + headers: getHeaders(), + body: JSON.stringify(data), + }); + if (!res.ok) throw new Error('API request failed'); + return res.json(); + }, + + delete: async (url: string): Promise => { + const res = await fetch(`${API_BASE_URL}${url}`, { + method: 'DELETE', + headers: getHeaders(), + }); + if (!res.ok) throw new Error('API request failed'); + return res.json(); + }, +}; diff --git a/dashboard/lib/auth-context.tsx b/dashboard/lib/auth-context.tsx new file mode 100644 index 0000000..b3d386b --- /dev/null +++ b/dashboard/lib/auth-context.tsx @@ -0,0 +1,102 @@ +'use client'; + +import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react'; +import { useRouter } from 'next/navigation'; +import type { User } from './types'; + +interface AuthContextType { + user: User | null; + token: string | null; + isLoading: boolean; + login: (email: string, password: string) => Promise<{ success: boolean; error?: string }>; + logout: () => void; + isAuthenticated: boolean; +} + +const AuthContext = createContext(undefined); + +const AUTH_TOKEN_KEY = 'factoryops_token'; +const AUTH_USER_KEY = 'factoryops_user'; + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null); + const [token, setToken] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const router = useRouter(); + + useEffect(() => { + const storedToken = localStorage.getItem(AUTH_TOKEN_KEY); + const storedUser = localStorage.getItem(AUTH_USER_KEY); + + if (storedToken && storedUser) { + setToken(storedToken); + setUser(JSON.parse(storedUser)); + } + setIsLoading(false); + }, []); + + const login = useCallback(async (email: string, password: string) => { + try { + const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'; + const res = await fetch(`${apiUrl}/api/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }); + + if (!res.ok) { + const error = await res.json().catch(() => ({ detail: '로그인에 실패했습니다.' })); + return { success: false, error: error.detail || '로그인에 실패했습니다.' }; + } + + const data = await res.json(); + const { access_token, user: userData } = data; + + localStorage.setItem(AUTH_TOKEN_KEY, access_token); + localStorage.setItem(AUTH_USER_KEY, JSON.stringify(userData)); + + setToken(access_token); + setUser(userData); + + return { success: true }; + } catch { + return { success: false, error: '서버 연결에 실패했습니다.' }; + } + }, []); + + const logout = useCallback(() => { + localStorage.removeItem(AUTH_TOKEN_KEY); + localStorage.removeItem(AUTH_USER_KEY); + setToken(null); + setUser(null); + router.push('/login'); + }, [router]); + + return ( + + {children} + + ); +} + +export function useAuth() { + const context = useContext(AuthContext); + if (context === undefined) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +} + +export function getStoredToken(): string | null { + if (typeof window === 'undefined') return null; + return localStorage.getItem(AUTH_TOKEN_KEY); +} diff --git a/dashboard/lib/hooks.ts b/dashboard/lib/hooks.ts new file mode 100644 index 0000000..333e289 --- /dev/null +++ b/dashboard/lib/hooks.ts @@ -0,0 +1,77 @@ +import useSWR from 'swr'; +import { fetcher, getTenantUrl } from './api'; +import type { Tenant, Machine, MachineDetail, EquipmentPart } from './types'; + +export function useTenants() { + const { data, error, isLoading, mutate } = useSWR<{ tenants: Tenant[] }>( + '/api/tenants', + fetcher, + ); + + return { + tenants: data?.tenants || [], + error, + isLoading, + mutate, + }; +} + +export function useTenantData(path: string, tenantId?: string) { + const url = tenantId ? getTenantUrl(path, tenantId) : null; + const { data, error, isLoading, mutate } = useSWR(url, fetcher); + + return { + data: data ?? null, + error, + isLoading, + mutate, + }; +} + +export function useMachines(tenantId?: string) { + const url = tenantId ? `/api/${tenantId}/machines` : null; + const { data, error, isLoading, mutate } = useSWR<{ machines: Machine[] }>( + url, + fetcher, + { refreshInterval: 30000, dedupingInterval: 2000 }, + ); + + return { + machines: data?.machines || [], + error, + isLoading, + mutate, + }; +} + +export function useMachine(tenantId?: string, machineId?: string) { + const url = tenantId && machineId ? `/api/${tenantId}/machines/${machineId}` : null; + const { data, error, isLoading, mutate } = useSWR( + url, + fetcher, + { refreshInterval: 30000, dedupingInterval: 2000 }, + ); + + return { + machine: data ?? null, + error, + isLoading, + mutate, + }; +} + +export function useEquipmentParts(tenantId?: string, machineId?: string) { + const url = tenantId && machineId ? `/api/${tenantId}/machines/${machineId}/parts` : null; + const { data, error, isLoading, mutate } = useSWR<{ parts: EquipmentPart[] }>( + url, + fetcher, + { refreshInterval: 30000, dedupingInterval: 2000 }, + ); + + return { + parts: data?.parts || [], + error, + isLoading, + mutate, + }; +} diff --git a/dashboard/lib/tenant-context.tsx b/dashboard/lib/tenant-context.tsx new file mode 100644 index 0000000..7eddd92 --- /dev/null +++ b/dashboard/lib/tenant-context.tsx @@ -0,0 +1,51 @@ +'use client'; + +import { createContext, useContext, useState, useEffect, ReactNode } from 'react'; +import { useParams } from 'next/navigation'; + +interface TenantContextType { + tenantId: string | null; + tenantName: string | null; + setTenant: (id: string, name: string) => void; +} + +const TenantContext = createContext(undefined); + +export function TenantProvider({ children }: { children: ReactNode }) { + const params = useParams(); + const [tenantId, setTenantId] = useState(null); + const [tenantName, setTenantName] = useState(null); + + useEffect(() => { + const tenant = params?.tenant as string | undefined; + if (tenant) { + setTenantId(tenant); + } + }, [params]); + + const setTenant = (id: string, name: string) => { + setTenantId(id); + setTenantName(name); + }; + + return ( + + {children} + + ); +} + +export function useTenant() { + const context = useContext(TenantContext); + if (context === undefined) { + throw new Error('useTenant must be used within a TenantProvider'); + } + return context; +} + +export function getTenantFromPath(): string | null { + if (typeof window === 'undefined') return null; + const path = window.location.pathname; + const match = path.match(/^\/([a-z0-9][a-z0-9-]{1,30}[a-z0-9])(?:\/|$)/); + return match ? match[1] : null; +} diff --git a/dashboard/lib/toast-context.tsx b/dashboard/lib/toast-context.tsx new file mode 100644 index 0000000..4c00db0 --- /dev/null +++ b/dashboard/lib/toast-context.tsx @@ -0,0 +1,47 @@ +'use client'; + +import React, { createContext, useContext, useState, useCallback } from 'react'; +import { Toast, ToastType } from '../components/Toast'; + +interface ToastContextType { + addToast: (message: string, type: ToastType, duration?: number) => void; + removeToast: (id: string) => void; +} + +const ToastContext = createContext(undefined); + +export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [toasts, setToasts] = useState>([]); + + const addToast = useCallback((message: string, type: ToastType, duration?: number) => { + const id = Math.random().toString(36).substr(2, 9); + setToasts((prev) => [...prev, { id, message, type, duration }]); + }, []); + + const removeToast = useCallback((id: string) => { + setToasts((prev) => prev.filter((toast) => toast.id !== id)); + }, []); + + return ( + + {children} +
+ {toasts.map((toast) => ( + + ))} +
+
+ ); +}; + +export const useToast = () => { + const context = useContext(ToastContext); + if (context === undefined) { + throw new Error('useToast must be used within a ToastProvider'); + } + return context; +}; diff --git a/dashboard/lib/types.ts b/dashboard/lib/types.ts new file mode 100644 index 0000000..795ba23 --- /dev/null +++ b/dashboard/lib/types.ts @@ -0,0 +1,63 @@ +export interface User { + id: string; + email: string; + name: string; + role: string; + tenant_id: string | null; + is_active: boolean; +} + +export interface Tenant { + id: string; + name: string; + industry_type: string; + is_active: boolean; + created_at: string | null; +} + +export interface Token { + access_token: string; + token_type: string; + user: User; +} + +export interface Machine { + id: string; + tenant_id: string; + name: string; + equipment_code: string; + model: string | null; + parts_count: number; + created_at: string | null; + updated_at: string | null; +} + +export interface PartCounter { + current_value: number; + lifecycle_pct: number; + last_reset_at: string | null; + last_updated_at: string | null; + version: number; +} + +export interface EquipmentPart { + id: string; + tenant_id: string; + machine_id: string; + name: string; + part_number: string | null; + category: string | null; + lifecycle_type: 'hours' | 'count' | 'date'; + lifecycle_limit: number; + alarm_threshold: number; + counter_source: 'auto_plc' | 'auto_time' | 'manual'; + installed_at: string | null; + is_active: boolean; + created_at: string | null; + updated_at: string | null; + counter?: PartCounter; +} + +export interface MachineDetail extends Machine { + parts: EquipmentPart[]; +} diff --git a/dashboard/next.config.ts b/dashboard/next.config.ts new file mode 100644 index 0000000..b070fd8 --- /dev/null +++ b/dashboard/next.config.ts @@ -0,0 +1,8 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + output: 'standalone', + devIndicators: false, +}; + +export default nextConfig; diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json new file mode 100644 index 0000000..1b25a36 --- /dev/null +++ b/dashboard/package-lock.json @@ -0,0 +1,5942 @@ +{ + "name": "dashboard", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "dashboard", + "version": "0.1.0", + "dependencies": { + "clsx": "^2.1.1", + "next": "16.1.6", + "react": "19.2.3", + "react-dom": "19.2.3", + "swr": "^2.4.0" + }, + "devDependencies": { + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "eslint": "^9", + "eslint-config-next": "16.1.6", + "typescript": "^5" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", + "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", + "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", + "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@img/colour": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", + "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } + }, + "node_modules/@next/env": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.6.tgz", + "integrity": "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==", + "license": "MIT" + }, + "node_modules/@next/eslint-plugin-next": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.1.6.tgz", + "integrity": "sha512-/Qq3PTagA6+nYVfryAtQ7/9FEr/6YVyvOtl6rZnGsbReGLf0jZU6gkpr1FuChAQpvV46a78p4cmHOVP8mbfSMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-glob": "3.3.1" + } + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.6.tgz", + "integrity": "sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.6.tgz", + "integrity": "sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.6.tgz", + "integrity": "sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.6.tgz", + "integrity": "sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.6.tgz", + "integrity": "sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.6.tgz", + "integrity": "sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.6.tgz", + "integrity": "sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.6.tgz", + "integrity": "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nolyfill/is-core-module": { + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz", + "integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.4.0" + } + }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.33", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz", + "integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.13", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.13.tgz", + "integrity": "sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.55.0.tgz", + "integrity": "sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.55.0", + "@typescript-eslint/type-utils": "8.55.0", + "@typescript-eslint/utils": "8.55.0", + "@typescript-eslint/visitor-keys": "8.55.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.55.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.55.0.tgz", + "integrity": "sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.55.0", + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/typescript-estree": "8.55.0", + "@typescript-eslint/visitor-keys": "8.55.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.55.0.tgz", + "integrity": "sha512-zRcVVPFUYWa3kNnjaZGXSu3xkKV1zXy8M4nO/pElzQhFweb7PPtluDLQtKArEOGmjXoRjnUZ29NjOiF0eCDkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.55.0", + "@typescript-eslint/types": "^8.55.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.55.0.tgz", + "integrity": "sha512-fVu5Omrd3jeqeQLiB9f1YsuK/iHFOwb04bCtY4BSCLgjNbOD33ZdV6KyEqplHr+IlpgT0QTZ/iJ+wT7hvTx49Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/visitor-keys": "8.55.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.55.0.tgz", + "integrity": "sha512-1R9cXqY7RQd7WuqSN47PK9EDpgFUK3VqdmbYrvWJZYDd0cavROGn+74ktWBlmJ13NXUQKlZ/iAEQHI/V0kKe0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.55.0.tgz", + "integrity": "sha512-x1iH2unH4qAt6I37I2CGlsNs+B9WGxurP2uyZLRz6UJoZWDBx9cJL1xVN/FiOmHEONEg6RIufdvyT0TEYIgC5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/typescript-estree": "8.55.0", + "@typescript-eslint/utils": "8.55.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.55.0.tgz", + "integrity": "sha512-ujT0Je8GI5BJWi+/mMoR0wxwVEQaxM+pi30xuMiJETlX80OPovb2p9E8ss87gnSVtYXtJoU9U1Cowcr6w2FE0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.55.0.tgz", + "integrity": "sha512-EwrH67bSWdx/3aRQhCoxDaHM+CrZjotc2UCCpEDVqfCE+7OjKAGWNY2HsCSTEVvWH2clYQK8pdeLp42EVs+xQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.55.0", + "@typescript-eslint/tsconfig-utils": "8.55.0", + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/visitor-keys": "8.55.0", + "debug": "^4.4.3", + "minimatch": "^9.0.5", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.55.0.tgz", + "integrity": "sha512-BqZEsnPGdYpgyEIkDC1BadNY8oMwckftxBT+C8W0g1iKPdeqKZBtTfnvcq0nf60u7MkjFO8RBvpRGZBPw4L2ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.55.0", + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/typescript-estree": "8.55.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.55.0.tgz", + "integrity": "sha512-AxNRwEie8Nn4eFS1FzDMJWIISMGoXMb037sgCBJ3UR6o0fQTzr2tqN9WT+DkWJPhIdQCfV7T6D387566VtnCJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.55.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", + "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", + "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", + "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", + "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", + "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", + "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", + "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", + "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", + "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", + "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", + "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", + "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", + "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", + "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", + "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", + "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^0.2.11" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", + "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", + "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", + "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", + "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-shim-unscopables": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ast-types-flow": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", + "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/axe-core": { + "version": "4.11.1", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.1.tgz", + "integrity": "sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", + "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001769", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001769.tgz", + "integrity": "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/damerau-levenshtein": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", + "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.286", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", + "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-abstract": { + "version": "1.24.1", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", + "integrity": "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.2.tgz", + "integrity": "sha512-BrUQ0cPTB/IwXj23HtwHjS9n7O4h9FX94b4xc5zlTHxeLgTAdzYUDyy6KdExAl9lbN5rtfe44xpjpmj9grxs5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.1", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.1.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.3.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.5", + "safe-array-concat": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", + "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.2", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-next": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.1.6.tgz", + "integrity": "sha512-vKq40io2B0XtkkNDYyleATwblNt8xuh3FWp8SpSz3pt7P01OkBFlKsJZ2mWt5WsCySlDQLckb1zMY9yE9Qy0LA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@next/eslint-plugin-next": "16.1.6", + "eslint-import-resolver-node": "^0.3.6", + "eslint-import-resolver-typescript": "^3.5.2", + "eslint-plugin-import": "^2.32.0", + "eslint-plugin-jsx-a11y": "^6.10.0", + "eslint-plugin-react": "^7.37.0", + "eslint-plugin-react-hooks": "^7.0.0", + "globals": "16.4.0", + "typescript-eslint": "^8.46.0" + }, + "peerDependencies": { + "eslint": ">=9.0.0", + "typescript": ">=3.3.1" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/eslint-config-next/node_modules/globals": { + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.4.0.tgz", + "integrity": "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-import-resolver-node": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", + "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.13.0", + "resolve": "^1.22.4" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-import-resolver-typescript": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.10.1.tgz", + "integrity": "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@nolyfill/is-core-module": "1.0.39", + "debug": "^4.4.0", + "get-tsconfig": "^4.10.0", + "is-bun-module": "^2.0.0", + "stable-hash": "^0.0.5", + "tinyglobby": "^0.2.13", + "unrs-resolver": "^1.6.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-resolver-typescript" + }, + "peerDependencies": { + "eslint": "*", + "eslint-plugin-import": "*", + "eslint-plugin-import-x": "*" + }, + "peerDependenciesMeta": { + "eslint-plugin-import": { + "optional": true + }, + "eslint-plugin-import-x": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", + "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import": { + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.12.1", + "hasown": "^2.0.2", + "is-core-module": "^2.16.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.1", + "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.9", + "tsconfig-paths": "^3.15.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-jsx-a11y": { + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", + "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "aria-query": "^5.3.2", + "array-includes": "^3.1.8", + "array.prototype.flatmap": "^1.3.2", + "ast-types-flow": "^0.0.8", + "axe-core": "^4.10.0", + "axobject-query": "^4.1.0", + "damerau-levenshtein": "^1.0.8", + "emoji-regex": "^9.2.2", + "hasown": "^2.0.2", + "jsx-ast-utils": "^3.3.5", + "language-tags": "^1.0.9", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "safe-regex-test": "^1.0.3", + "string.prototype.includes": "^2.0.1" + }, + "engines": { + "node": ">=4.0" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react/node_modules/resolve": { + "version": "2.0.0-next.5", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", + "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", + "integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bun-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", + "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.7.1" + } + }, + "node_modules/is-bun-module/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/language-subtag-registry": { + "version": "0.3.23", + "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", + "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/language-tags": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", + "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "language-subtag-registry": "^0.3.20" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/napi-postinstall": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/next": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/next/-/next-16.1.6.tgz", + "integrity": "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==", + "license": "MIT", + "dependencies": { + "@next/env": "16.1.6", + "@swc/helpers": "0.5.15", + "baseline-browser-mapping": "^2.8.3", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=20.9.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "16.1.6", + "@next/swc-darwin-x64": "16.1.6", + "@next/swc-linux-arm64-gnu": "16.1.6", + "@next/swc-linux-arm64-musl": "16.1.6", + "@next/swc-linux-x64-gnu": "16.1.6", + "@next/swc-linux-x64-musl": "16.1.6", + "@next/swc-win32-arm64-msvc": "16.1.6", + "@next/swc-win32-x64-msvc": "16.1.6", + "sharp": "^0.34.4" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.groupby": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", + "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", + "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.3" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/sharp/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stable-hash": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", + "integrity": "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.includes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", + "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/swr": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/swr/-/swr-2.4.0.tgz", + "integrity": "sha512-sUlC20T8EOt1pHmDiqueUWMmRRX03W7w5YxovWX7VR2KHEPCTMly85x05vpkP5i6Bu4h44ePSMD9Tc+G2MItFw==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-api-utils": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tsconfig-paths": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "node_modules/tsconfig-paths/node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.55.0.tgz", + "integrity": "sha512-HE4wj+r5lmDVS9gdaN0/+iqNvPZwGfnJ5lZuz7s5vLlg9ODw0bIiiETaios9LvFI1U94/VBXGm3CB2Y5cNFMpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.55.0", + "@typescript-eslint/parser": "8.55.0", + "@typescript-eslint/typescript-estree": "8.55.0", + "@typescript-eslint/utils": "8.55.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unrs-resolver": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", + "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.0" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.11.1", + "@unrs/resolver-binding-android-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-x64": "1.11.1", + "@unrs/resolver-binding-freebsd-x64": "1.11.1", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", + "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-musl": "1.11.1", + "@unrs/resolver-binding-wasm32-wasi": "1.11.1", + "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", + "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", + "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/dashboard/package.json b/dashboard/package.json new file mode 100644 index 0000000..304396e --- /dev/null +++ b/dashboard/package.json @@ -0,0 +1,26 @@ +{ + "name": "dashboard", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "eslint" + }, + "dependencies": { + "clsx": "^2.1.1", + "next": "16.1.6", + "react": "19.2.3", + "react-dom": "19.2.3", + "swr": "^2.4.0" + }, + "devDependencies": { + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "eslint": "^9", + "eslint-config-next": "16.1.6", + "typescript": "^5" + } +} diff --git a/dashboard/public/file.svg b/dashboard/public/file.svg new file mode 100644 index 0000000..004145c --- /dev/null +++ b/dashboard/public/file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashboard/public/globe.svg b/dashboard/public/globe.svg new file mode 100644 index 0000000..567f17b --- /dev/null +++ b/dashboard/public/globe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashboard/public/next.svg b/dashboard/public/next.svg new file mode 100644 index 0000000..5174b28 --- /dev/null +++ b/dashboard/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashboard/public/vercel.svg b/dashboard/public/vercel.svg new file mode 100644 index 0000000..7705396 --- /dev/null +++ b/dashboard/public/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashboard/public/window.svg b/dashboard/public/window.svg new file mode 100644 index 0000000..b2b2a44 --- /dev/null +++ b/dashboard/public/window.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashboard/tsconfig.json b/dashboard/tsconfig.json new file mode 100644 index 0000000..3a13f90 --- /dev/null +++ b/dashboard/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./*"] + } + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts", + "**/*.mts" + ], + "exclude": ["node_modules"] +} diff --git a/main.py b/main.py new file mode 100644 index 0000000..d585634 --- /dev/null +++ b/main.py @@ -0,0 +1,136 @@ +import logging +import os +from contextlib import asynccontextmanager +from typing import List + +from dotenv import load_dotenv + +load_dotenv() + +from fastapi import FastAPI, Depends, HTTPException, Path +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel +from sqlalchemy.ext.asyncio import AsyncSession + +from src.database.config import get_db, init_db +from src.auth.router import router as auth_router, admin_router as auth_admin_router +from src.auth.dependencies import require_auth, require_superadmin +from src.auth.models import TokenData +from src.auth.password import hash_password +from src.tenant import manager as tenant_manager +from src.tenant.manager import TenantNotFoundError, InvalidTenantIdError +from src.api.machines import router as machines_router +from src.api.equipment_parts import router as equipment_parts_router + +logger = logging.getLogger(__name__) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + await init_db() + logger.info("Database initialized") + yield + + +app = FastAPI(title="FactoryOps v2 API", lifespan=lifespan) + +app.include_router(auth_router) +app.include_router(auth_admin_router) +app.include_router(machines_router) +app.include_router(equipment_parts_router) + +CORS_ORIGINS = ( + os.getenv("CORS_ORIGINS", "").split(",") if os.getenv("CORS_ORIGINS") else [] +) +if not CORS_ORIGINS: + CORS_ORIGINS = ["http://localhost:3000", "http://127.0.0.1:3000"] +app.add_middleware( + CORSMiddleware, + allow_origins=CORS_ORIGINS, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +@app.get("/api/health") +async def health_check(): + return {"status": "ok", "version": "2.0.0"} + + +class TenantCreate(BaseModel): + id: str + name: str + industry_type: str = "general" + + +@app.get("/api/tenants") +async def list_accessible_tenants( + current_user: TokenData = Depends(require_auth), + db: AsyncSession = Depends(get_db), +): + all_tenants = await tenant_manager.list_tenants(db) + + if current_user.role == "superadmin": + return {"tenants": [t for t in all_tenants if t.get("is_active", True)]} + + if current_user.tenant_id: + return { + "tenants": [ + t + for t in all_tenants + if t["id"] == current_user.tenant_id and t.get("is_active", True) + ] + } + + return {"tenants": []} + + +@app.get("/api/admin/tenants") +async def list_all_tenants( + current_user: TokenData = Depends(require_superadmin), + db: AsyncSession = Depends(get_db), +): + return {"tenants": await tenant_manager.list_tenants(db)} + + +@app.post("/api/admin/tenants") +async def create_tenant( + tenant: TenantCreate, + current_user: TokenData = Depends(require_superadmin), + db: AsyncSession = Depends(get_db), +): + try: + result = await tenant_manager.create_tenant( + db, tenant.id, tenant.name, tenant.industry_type + ) + return {"status": "success", "tenant": result} + except InvalidTenantIdError as e: + raise HTTPException(status_code=400, detail=str(e)) + except ValueError as e: + raise HTTPException(status_code=409, detail=str(e)) + + +@app.get( + "/api/admin/tenants/{tenant_id}", +) +async def get_tenant( + tenant_id: str = Path(..., pattern=r"^[a-z0-9][a-z0-9-]{1,30}[a-z0-9]$"), + current_user: TokenData = Depends(require_superadmin), + db: AsyncSession = Depends(get_db), +): + try: + return await tenant_manager.get_tenant(db, tenant_id) + except TenantNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run( + "main:app", + host=os.getenv("HOST", "0.0.0.0"), + port=int(os.getenv("PORT", "8000")), + reload=True, + ) diff --git a/planning/development-plan.md b/planning/development-plan.md new file mode 100644 index 0000000..955bd2d --- /dev/null +++ b/planning/development-plan.md @@ -0,0 +1,1048 @@ +# FactoryOps v2 — 개발 계획서 (Complete Plan) + +> 작성일: 2026-02-09 (최종 업데이트: 2026-02-10) +> 기반 자료: 개발회의.md (멘토 회의), spifox-inspection-analysis.md (아키텍처 분석), integrated-analysis.md (3사 통합분석), Oracle 컨설팅 +> 대상 고객사: 스피폭스(SpiFox), 엔키드(Enkid), 알펫(Alpet) +> ⚠️ **새 프로젝트** — 기존 factoryOps 코드베이스는 **참고만** 하며, 필요한 코드만 발췌하여 새로 작성합니다. +> ⚠️ **PostgreSQL 전용** — SQLite fallback 없음. 테스트 DB도 PostgreSQL. JSONB, Array 등 PostgreSQL 네이티브 기능 적극 활용. + +### 핵심 설계 원칙 (2026-02-10 추가) + +> **"TYPE 필드로 분기하고, 테넌트 ID로는 절대 분기하지 않는다."** +> — Oracle Architecture Consultation + +- 업체별 차이는 `lifecycle_type`, `data_type`, `inspection_mode`, `alarm_type` 등 엔티티의 TYPE 필드에서 결정 +- 코어 앱에 `if tenant == "spifox"` 같은 분기 = **0개** +- 상세 분석: `planning/integrated-analysis.md` 참조 + +--- + +## 0. 프로젝트 셋업 절차 + +### 디렉토리 전환 + +```bash +mv /Users/johngreen/Dev/factoryOps /Users/johngreen/Dev/factoryops_backup +mkdir /Users/johngreen/Dev/factoryOps +cd /Users/johngreen/Dev/factoryOps +git init +``` + +### 새 프로젝트 디렉토리 구조 + +``` +factoryOps/ +├── main.py # FastAPI 앱 엔트리포인트 (최소화, 목표 100줄 이내) +├── requirements.txt # Python 의존성 (경량화) +├── alembic.ini # DB 마이그레이션 설정 +├── alembic/ +│ ├── env.py +│ └── versions/ # 마이그레이션 파일 +├── src/ +│ ├── __init__.py +│ ├── auth/ # ✨ 기존 참고하여 새로 작성 +│ │ ├── __init__.py +│ │ ├── jwt_handler.py # JWT 토큰 생성/검증 (HS256, 24h 만료) +│ │ ├── password.py # bcrypt 해싱 +│ │ ├── dependencies.py # FastAPI auth 의존성 (get_current_user, require_auth 등) +│ │ ├── models.py # Pydantic auth 모델 (User, Token, TokenData 등) +│ │ ├── service.py # 인증 비즈니스 로직 (login, create_user 등) +│ │ └── router.py # 인증 API 엔드포인트 (/api/auth/login, /me 등) +│ ├── database/ +│ │ ├── __init__.py +│ │ ├── config.py # ✨ 새로 작성 (PostgreSQL 전용, asyncpg + SQLAlchemy async) +│ │ └── models.py # ✨ 새로 작성 (아래 스키마 참조) +│ ├── tenant/ +│ │ ├── __init__.py +│ │ └── manager.py # ✨ 기존 참고하여 새로 작성 (간소화) +│ ├── api/ # ✨ 모두 새로 작성 +│ │ ├── __init__.py +│ │ ├── machines.py # 설비 CRUD +│ │ ├── equipment_parts.py # 설비 부품 관리 +│ │ ├── templates.py # 검사 템플릿 CRUD +│ │ ├── sessions.py # 검사 세션 (실행 + 자동저장) +│ │ ├── counters.py # 부품 카운터 + 교체 +│ │ └── alarms.py # 알람 조회/확인 +│ └── services/ # ✨ 비즈니스 로직 분리 +│ ├── __init__.py +│ ├── counter_service.py # 카운터 갱신/리셋 로직 (optimistic locking) +│ └── alarm_service.py # 알람 발생/조회 로직 +├── tests/ +│ ├── conftest.py # pytest fixtures (PostgreSQL 테스트 DB, test client, test user) +│ ├── test_auth.py +│ ├── test_machines.py +│ ├── test_templates.py +│ ├── test_sessions.py +│ ├── test_counters.py +│ └── test_alarms.py +├── dashboard/ # Next.js 프론트엔드 +│ ├── app/ +│ │ ├── layout.tsx # ✨ 기존 참고하여 새로 작성 (Providers 래핑) +│ │ ├── page.tsx # 테넌트 선택 (superadmin) / 리다이렉트 +│ │ ├── login/page.tsx # ✨ 기존 참고하여 새로 작성 +│ │ ├── globals.css # ✨ 기존 참고하여 새로 작성 (Material Design 3 CSS 변수) +│ │ └── [tenant]/ +│ │ ├── layout.tsx # 테넌트 레이아웃 (TopNav + AuthGuard) +│ │ ├── page.tsx # 대시보드 (설비 목록 + 부품 현황 + 교체 임박) +│ │ ├── machines/ +│ │ │ └── [id]/page.tsx # 설비 상세 (부품 탭, 카운터 탭, 교체 이력) +│ │ ├── templates/ +│ │ │ ├── page.tsx # 검사 템플릿 목록 (설비/품질 탭) +│ │ │ ├── new/page.tsx # 템플릿 생성 +│ │ │ └── [id]/page.tsx # 템플릿 편집 +│ │ ├── inspections/ +│ │ │ ├── page.tsx # 검사 세션 목록 (진행중/완료 탭) +│ │ │ └── [id]/page.tsx # 검사 실행 화면 (자동저장) +│ │ ├── alarms/ +│ │ │ └── page.tsx # 알람 목록 (필터: 타입/심각도/확인여부) +│ │ └── settings/ +│ │ └── page.tsx # 테넌트 설정 +│ ├── components/ # ✨ 새로 작성 (기존 패턴 참고) +│ │ ├── TopNav.tsx # ✨ 새로 작성 (설비/검사/알람 메뉴) +│ │ ├── AuthGuard.tsx # ✨ 기존 참고하여 새로 작성 +│ │ ├── Toast.tsx # ✨ 기존 참고하여 새로 작성 +│ │ ├── Card.tsx # ✨ 기존 참고하여 새로 작성 +│ │ ├── MachineList.tsx # 설비 목록 (간소화 재작성) +│ │ ├── PartLifecycleGauge.tsx # 부품 수명 게이지 (원형/바, 새로) +│ │ ├── InspectionForm.tsx # 검사 실행 폼 (핵심, 새로, 자동저장) +│ │ ├── TemplateEditor.tsx # 템플릿 항목 편집기 (새로) +│ │ ├── AlarmBadge.tsx # 알람 뱃지 (새로) +│ │ └── CounterCard.tsx # 카운터 현황 카드 (새로) +│ ├── lib/ +│ │ ├── api.ts # ✨ 기존 참고하여 새로 작성 (fetcher, api.get/post/put/delete) +│ │ ├── auth-context.tsx # ✨ 기존 참고하여 새로 작성 (JWT localStorage 관리) +│ │ ├── tenant-context.tsx # ✨ 기존 참고하여 새로 작성 (URL에서 tenant_id 추출) +│ │ ├── toast-context.tsx # ✨ 기존 참고하여 새로 작성 +│ │ ├── hooks.ts # ✨ 새 도메인 훅 (useData 패턴 유지) +│ │ └── types.ts # ✨ 새 타입 정의 +│ ├── package.json +│ ├── next.config.ts +│ └── tsconfig.json +├── planning/ # 작업 메모리 +│ ├── task_plan.md +│ ├── findings.md +│ └── progress.md +├── CLAUDE.md # 프로젝트 가이드 +└── .env.example # 환경변수 템플릿 +``` + +--- + +## 1. 데이터베이스 스키마 (전체) + +> **Note**: PostgreSQL 전용 — JSONB (인덱싱, 쿼리 가능), UUID 네이티브 타입, TIMESTAMP WITH TIME ZONE, GIN 인덱스 활용 +> SQLite 호환성 고려 불필요. PostgreSQL 전용 기능을 적극 사용합니다. + +### 기본 테이블 (3개) — 새로 작성 + +```sql +-- 사용자 +User + id UUID PK DEFAULT gen_random_uuid() + email VARCHAR(255) UNIQUE NOT NULL + password_hash VARCHAR(255) NOT NULL + name VARCHAR(100) NOT NULL + role VARCHAR(20) NOT NULL -- 'superadmin' | 'tenant_admin' | 'user' + tenant_id VARCHAR(50) NULL + is_active BOOLEAN DEFAULT true + created_at TIMESTAMPTZ DEFAULT now() + updated_at TIMESTAMPTZ DEFAULT now() + +-- 테넌트 +Tenant + id VARCHAR(50) PK -- 예: 'spifox' + name VARCHAR(100) NOT NULL + industry_type VARCHAR(50) DEFAULT 'general' + is_active BOOLEAN DEFAULT true + enabled_modules JSONB NULL -- {"inspection": true, ...} + workflow_config JSONB NULL -- GIN 인덱스 사용 가능 + created_at TIMESTAMPTZ DEFAULT now() + +-- 설비 +Machine + id UUID PK DEFAULT gen_random_uuid() + tenant_id VARCHAR(50) FK → tenants.id NOT NULL + name VARCHAR(100) NOT NULL -- "1호기", "프레스 A-01" + equipment_code VARCHAR(50) DEFAULT '' -- 설비코드 + model VARCHAR(100) NULL -- 모델명 + created_at TIMESTAMPTZ DEFAULT now() + updated_at TIMESTAMPTZ DEFAULT now() + INDEX(tenant_id) +``` + +### 도메인 테이블 (7개) — 새로 작성 + +```sql +-- 1. 설비 장착 부품 (EquipmentPart) +-- 재고 부품이 아닌, 설비에 실제 장착된 부품 인스턴스 +-- 각 부품은 수명 관리 방식(시간/타발수/날짜)과 한계값을 가짐 +EquipmentPart + id UUID PK DEFAULT gen_random_uuid() + tenant_id VARCHAR(50) FK → tenants.id NOT NULL + machine_id UUID FK → machines.id NOT NULL + name VARCHAR(100) NOT NULL -- "상부 금형", "하부 금형", "플런저 팁" + part_number VARCHAR(50) NULL -- 부품번호 (optional, 재고 연결용) + category VARCHAR(50) NULL -- "mold", "tip", "sensor", "cylinder", "chemical", "heater" + lifecycle_type VARCHAR(20) NOT NULL -- "hours" | "count" | "date" + lifecycle_limit FLOAT NOT NULL -- 수명 한계값 (예: 10000타, 5000시간, 30일) + alarm_threshold FLOAT DEFAULT 90.0 -- 알람 시작 % (예: 90.0 = 90%에 도달하면 알람) + counter_source VARCHAR(20) DEFAULT 'manual' -- ✨ 신규: "auto_plc" | "auto_time" | "manual" + -- auto_plc: PLC에서 자동 갱신 (스피폭스 shotno, 엔키드 shots) + -- auto_time: 장착일 기준 경과시간 자동 계산 (알펫 히터, 약품) + -- manual: 사용자가 직접 입력 + installed_at TIMESTAMPTZ NOT NULL -- 현재 부품 장착일 + is_active BOOLEAN DEFAULT true + created_at TIMESTAMPTZ DEFAULT now() + updated_at TIMESTAMPTZ DEFAULT now() + UNIQUE(tenant_id, machine_id, name) + INDEX(tenant_id) + INDEX(tenant_id, machine_id) + +-- 2. 검사 템플릿 (InspectionTemplate) +-- 설비검사와 품질검사를 subject_type으로 구분하는 공용 템플릿 +-- 하나의 엔진, 이중 대상 원칙 (선배 개발자 + Oracle 컨설팅 합의) +InspectionTemplate + id UUID PK DEFAULT gen_random_uuid() + tenant_id VARCHAR(50) FK → tenants.id NOT NULL + name VARCHAR(200) NOT NULL -- "1호기 일일검사", "완제품 A-Type 검사" + subject_type VARCHAR(20) NOT NULL -- "equipment" | "product" + machine_id UUID FK → machines.id NULL -- subject_type='equipment'일 때 + product_code VARCHAR(50) NULL -- subject_type='product'일 때 (미래 확장) + schedule_type VARCHAR(20) NOT NULL -- "daily"|"weekly"|"monthly"|"yearly"|"ad_hoc" + inspection_mode VARCHAR(20) DEFAULT 'measurement' -- ✨ 신규: "checklist" | "measurement" | "monitoring" + -- checklist: 빠른 OK/NG 체크 (스피폭스 순회검사) + -- measurement: 정밀 측정 폼 (엔키드 품질검사) + -- monitoring: 연속 모니터링 (알펫 라인 관측) + -- 📌 테넌트가 아닌 템플릿에 설정 → 같은 업체도 검사별로 다른 모드 사용 가능 + version INTEGER DEFAULT 1 -- 버전 관리 (수정 시 증가) + is_active BOOLEAN DEFAULT true + created_at TIMESTAMPTZ DEFAULT now() + updated_at TIMESTAMPTZ DEFAULT now() + INDEX(tenant_id, subject_type) + INDEX(tenant_id, machine_id) + +-- 3. 검사 항목 정의 (InspectionTemplateItem) +-- 각 템플릿에 속하는 검사 항목들. 사용자가 자유롭게 추가/삭제 가능 +-- data_type에 따라 입력 방식이 달라짐 (numeric, boolean, text, select) +InspectionTemplateItem + id UUID PK DEFAULT gen_random_uuid() + template_id UUID FK → inspection_templates.id ON DELETE CASCADE + sort_order INTEGER NOT NULL -- 표시 순서 + name VARCHAR(200) NOT NULL -- "유압 압력", "금형 온도", "외관 상태" + category VARCHAR(100) NULL -- "안전", "작동", "품질", "청소" + data_type VARCHAR(20) NOT NULL -- "numeric"|"boolean"|"text"|"select" + unit VARCHAR(20) NULL -- "℃", "bar", "mm", "개" (numeric일 때) + spec_min FLOAT NULL -- 하한 스펙 (numeric일 때, NULL이면 체크 안 함) + spec_max FLOAT NULL -- 상한 스펙 (numeric일 때, NULL이면 체크 안 함) + warning_min FLOAT NULL -- ✨ 신규: 소프트 하한 (trend_warning용, 경고구간 시작) + warning_max FLOAT NULL -- ✨ 신규: 소프트 상한 (trend_warning용, 경고구간 시작) + trend_window INTEGER NULL -- ✨ 신규: 연속 N회 경고구간 진입 시 trend_warning 알람 (NULL=비활성) + select_options JSONB NULL -- data_type='select'일 때 ["양호","불량","N/A"] + equipment_part_id UUID FK → equipment_parts.id NULL -- 부품 연결 (optional) + is_required BOOLEAN DEFAULT true -- 필수 입력 여부 + created_at TIMESTAMPTZ DEFAULT now() + INDEX(template_id, sort_order) + +-- 4. 검사 세션 (InspectionSession) +-- 검사 실행 단위. "진행 중 저장"이 핵심 요구사항. +-- 세션 시작 시 template_snapshot에 템플릿+항목 전체를 JSON으로 스냅샷하여 +-- 이후 템플릿이 수정되어도 기존 검사 이력이 보호됨 (버전 보호) +InspectionSession + id UUID PK DEFAULT gen_random_uuid() + tenant_id VARCHAR(50) FK → tenants.id NOT NULL + template_id UUID FK → inspection_templates.id NOT NULL + template_snapshot JSONB NOT NULL -- 세션 시작 시 템플릿+항목 전체 스냅샷 + status VARCHAR(20) NOT NULL DEFAULT 'in_progress' + -- "in_progress"|"completed"|"cancelled" + inspector_id UUID FK → users.id NOT NULL -- 검사자 + started_at TIMESTAMPTZ NOT NULL + completed_at TIMESTAMPTZ NULL + results JSONB NULL -- 완료 시 최종 결과 (항목별 값 + 판정) + partial_results JSONB NULL -- 진행 중 자동저장 데이터 + -- {item_id: {value: ..., updated_at: ...}} + lot_number VARCHAR(100) NULL -- ✨ 신규: 로트/시리얼 번호 (엔키드 IATF 추적성용, optional) + notes TEXT NULL -- 비고/메모 + created_at TIMESTAMPTZ DEFAULT now() + updated_at TIMESTAMPTZ DEFAULT now() + INDEX(tenant_id, status) + INDEX(tenant_id, template_id) + INDEX(tenant_id, inspector_id, status) + +-- 5. 부품 카운터 (PartCounter) +-- 설비별 부품별 1:1 mutable 레코드. 누적값을 추적하며 부품 교체 시 리셋. +-- version 필드로 optimistic locking (PLC 갱신과 수동 리셋 동시 발생 방지) +PartCounter + id UUID PK DEFAULT gen_random_uuid() + tenant_id VARCHAR(50) FK → tenants.id NOT NULL + equipment_part_id UUID FK → equipment_parts.id UNIQUE NOT NULL + current_value FLOAT DEFAULT 0 -- 현재 누적값 (타발수, 시간 등) + lifecycle_pct FLOAT DEFAULT 0 -- 수명 진행률 (%) = current_value / lifecycle_limit * 100 + last_reset_at TIMESTAMPTZ NOT NULL -- 마지막 리셋(교체) 시각 + last_updated_at TIMESTAMPTZ NOT NULL -- 마지막 갱신 시각 + version INTEGER DEFAULT 0 -- optimistic locking용 버전 + INDEX(tenant_id) + INDEX(equipment_part_id) + +-- 6. 부품 교체 이력 (PartReplacementLog) +-- 부품 교체 시점의 스냅샷을 보존. "언제, 누가, 누적값 얼마에서 교체했는가" +PartReplacementLog + id UUID PK DEFAULT gen_random_uuid() + tenant_id VARCHAR(50) FK → tenants.id NOT NULL + equipment_part_id UUID FK → equipment_parts.id NOT NULL + replaced_by UUID FK → users.id NULL -- 교체한 사람 + replaced_at TIMESTAMPTZ NOT NULL + counter_at_replacement FLOAT NOT NULL -- 교체 시점 카운터 값 (예: 9,850타) + lifecycle_pct_at_replacement FLOAT NOT NULL -- 교체 시점 수명 % (예: 98.5%) + reason VARCHAR(200) NULL -- 교체 사유 ("정기 교체", "마모", "파손" 등) + notes TEXT NULL + INDEX(tenant_id, equipment_part_id) + INDEX(tenant_id, replaced_at) + +-- 7. 검사 알람 (InspectionAlarm) +-- 3가지 알람 유형: +-- part_lifecycle: 부품 수명 임계값 도달 (예: 90% 도달) +-- inspection_overdue: 정기검사 미실시 (예: 일일검사를 오늘 안 함) +-- spec_violation: 검사 결과 스펙 이탈 (예: 온도 65℃, 상한 60℃) +InspectionAlarm + id UUID PK DEFAULT gen_random_uuid() + tenant_id VARCHAR(50) FK → tenants.id NOT NULL + alarm_type VARCHAR(30) NOT NULL -- "part_lifecycle"|"inspection_overdue"|"spec_violation"|"trend_warning" + severity VARCHAR(10) NOT NULL -- "warning"|"critical" + machine_id UUID FK → machines.id NULL + equipment_part_id UUID FK → equipment_parts.id NULL + session_id UUID FK → inspection_sessions.id NULL + title VARCHAR(200) NOT NULL -- "1호기 상부금형 수명 92% 도달" + message TEXT NULL -- 상세 설명 + is_acknowledged BOOLEAN DEFAULT false + acknowledged_by UUID FK → users.id NULL + acknowledged_at TIMESTAMPTZ NULL + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + INDEX(tenant_id, is_acknowledged) + INDEX(tenant_id, alarm_type) + INDEX(tenant_id, created_at) +``` + +### 테이블 관계 다이어그램 + +``` +Tenant (1) ──────┬──── (N) User + │ + ├──── (N) Machine + │ │ + │ ├──── (N) EquipmentPart + │ │ │ + │ │ ├──── (1) PartCounter + │ │ │ + │ │ ├──── (N) PartReplacementLog + │ │ │ + │ │ └──── (N) InspectionTemplateItem.equipment_part_id + │ │ + │ └──── (N) InspectionTemplate.machine_id + │ │ + │ ├──── (N) InspectionTemplateItem + │ │ + │ └──── (N) InspectionSession + │ + └──── (N) InspectionAlarm +``` + +--- + +## 2. Phase 분해 + +--- + +### Phase 0: 프로젝트 부트스트래핑 (0.5일) + +| # | 작업 | 상세 | 카테고리 | 스킬 | +| --- | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ----- | +| 0-1 | 기존 프로젝트 백업 | `mv factoryOps factoryops_backup` (기존 코드는 참고용으로 보존) | infra | bash | +| 0-2 | 새 프로젝트 생성 | `mkdir factoryOps && cd factoryOps && git init` | infra | bash | +| 0-3 | `requirements.txt` 작성 | FastAPI==0.115.0, SQLAlchemy[asyncio]==2.0.36, asyncpg==0.30.0, PyJWT==2.9.0, bcrypt==4.2.0, alembic==1.14.0, uvicorn[standard], python-multipart, pytest==8.2.2, pytest-asyncio, httpx | infra | write | +| 0-4 | Next.js 프로젝트 초기화 | `npx create-next-app@latest dashboard --typescript --app --no-tailwind` 후 `npm install swr clsx` | infra | bash | +| 0-5 | `next.config.ts` 설정 | `output: 'standalone'`, 기존 참고하여 작성 | frontend | write | +| 0-6 | `tsconfig.json` 설정 | `@/*` path alias, strict mode, 기존 참고하여 작성 | frontend | write | +| 0-7 | `.env.example` 작성 | DATABASE_URL (postgresql+asyncpg://...), TEST_DATABASE_URL, JWT_SECRET_KEY, SQL_ECHO, NEXT_PUBLIC_API_URL | infra | write | +| 0-8 | `CLAUDE.md` 작성 | 새 프로젝트 기술스택, 구조, 스킬 사용법 가이드 | docs | write | +| 0-9 | `planning/` 디렉토리 생성 | task_plan.md, findings.md, progress.md 초기화 | docs | write | + +**의존성**: 없음 (최초 작업) +**병렬 가능**: 0-3(Python)과 0-4(Node.js)는 0-2 이후 병렬 가능 +**예상 소요**: 0.5일 + +--- + +### Phase 1: 인증 + DB 기반 (1.5일) + +| # | 작업 | 상세 | 카테고리 | 스킬 | +| ------------------ | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ----- | +| **Backend** | | | | | +| 1-1 | `src/database/config.py` 작성 | PostgreSQL 전용 (asyncpg). AsyncSession, async get_db(), async init_db(). SQLAlchemy async engine 사용 | backend | write | +| 1-2 | `src/database/models.py` 작성 | User, Tenant, Machine 3개 테이블만 (목표 150줄). Base = declarative_base(). PostgreSQL UUID, TIMESTAMPTZ, JSONB 네이티브 타입 사용 | backend | write | +| 1-3 | `src/auth/` 작성 | 기존 코드 참고하여 새로 작성. jwt_handler.py, password.py, dependencies.py, models.py, service.py, router.py,`__init__.py`. 핵심: login, logout, /me, create_user, list_users | backend | write | +| 1-4 | (1-3에 통합) | — | — | — | +| 1-5 | `src/tenant/manager.py` 작성 | 기존 참고하여 간소화 작성. 핵심만 유지: get_tenant(), create_tenant(), list_tenants(), verify_tenant_access() | backend | write | +| 1-6 | `main.py` 작성 | 최소 FastAPI app. auth_router 등록, CORS middleware, init_db() on startup. 기존 2000줄 → 목표 60~80줄 | backend | write | +| 1-7 | Alembic 설정 | `alembic init alembic`, env.py에 models import + database URL 설정, 초기 마이그레이션 `alembic revision --autogenerate -m "initial"` | backend | bash | +| 1-8 | `tests/conftest.py` 작성 | PostgreSQL 테스트 DB (TEST_DATABASE_URL), async test SessionLocal, TestClient fixture (httpx.AsyncClient), create_test_user() fixture (superadmin + regular user), get_auth_headers() helper. 각 테스트 후 트랜잭션 롤백으로 격리 | backend | write | +| 1-9 | `tests/test_auth.py` 작성 | 로그인 성공/실패, 토큰 검증, /me 엔드포인트, 권한 체크 (superadmin vs user) | backend | write | +| **Frontend** | | | | | +| 1-10 | 프론트엔드 기반 파일 작성 | 기존 참고하여 새로 작성:`lib/api.ts`, `lib/auth-context.tsx`, `lib/tenant-context.tsx`, `lib/toast-context.tsx`, `app/layout.tsx`, `app/globals.css` (Material Design 3), `components/AuthGuard.tsx`, `components/Toast.tsx`, `components/Card.tsx` | frontend | write | +| 1-11 | `app/login/page.tsx` 작성 | email/password 입력, POST /api/auth/login, 성공 시 리다이렉트. 기존 패턴 참고 | frontend | write | +| 1-12 | `app/page.tsx` 작성 | 테넌트 선택 페이지 (superadmin) 또는 자동 리다이렉트 (일반 사용자) | frontend | write | + +**의존성**: Phase 0 완료 후 +**병렬 가능**: 백엔드(1-1~1-9)와 프론트엔드(1-10~1-12) 완전 병렬 +**예상 소요**: 1.5일 +**검증 기준**: + +- `pytest tests/test_auth.py` 전체 통과 +- 브라우저에서 로그인 → /me 응답 확인 +- `/serve` 스킬로 서버 실행 가능 + +--- + +### Phase 2: 설비 + 부품 관리 (2일) + +| # | 작업 | 상세 | 카테고리 | 스킬 | +| ------------------ | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ----- | +| **Backend** | | | | | +| 2-1 | `models.py`에 EquipmentPart 모델 추가 | 위 스키마 정의 참조. Machine과 1:N 관계 설정. UNIQUE(tenant_id, machine_id, name) 제약 | backend | edit | +| 2-2 | `src/api/machines.py` — 설비 CRUD API | | backend | write | +| | `GET /api/{tenant_id}/machines` | 테넌트 전체 설비 목록. 응답에 parts_count 포함 | | | +| | `POST /api/{tenant_id}/machines` | 설비 등록 (name, equipment_code, model) | | | +| | `GET /api/{tenant_id}/machines/{id}` | 설비 상세 + 장착 부품 목록 포함 | | | +| | `PUT /api/{tenant_id}/machines/{id}` | 설비 수정 | | | +| | `DELETE /api/{tenant_id}/machines/{id}` | 설비 삭제 (부품 있으면 soft delete 또는 거부) | | | +| 2-3 | `src/api/equipment_parts.py` — 부품 CRUD API | | backend | write | +| | `GET /api/{tenant_id}/machines/{machine_id}/parts` | 설비의 전체 부품 목록 | | | +| | `POST /api/{tenant_id}/machines/{machine_id}/parts` | 부품 등록 (name, category, lifecycle_type, lifecycle_limit, alarm_threshold, installed_at). 동시에 PartCounter 레코드도 자동 생성 (current_value=0) | | | +| | `GET /api/{tenant_id}/parts/{id}` | 부품 상세 (카운터 포함) | | | +| | `PUT /api/{tenant_id}/parts/{id}` | 부품 정보 수정 | | | +| | `DELETE /api/{tenant_id}/parts/{id}` | 부품 삭제 (비활성화) | | | +| 2-4 | `main.py`에 machines, equipment_parts 라우터 등록 | router include | backend | edit | +| 2-5 | Alembic 마이그레이션 생성 | `alembic revision --autogenerate -m "add_equipment_part"` | backend | bash | +| 2-6 | `tests/test_machines.py` | 설비 CRUD 5개 엔드포인트 테스트, 테넌트 격리 확인 | backend | write | +| 2-7 | `tests/test_equipment_parts.py` | 부품 CRUD 테스트, 부품 등록 시 PartCounter 자동 생성 확인, 중복 이름 거부 확인 | backend | write | +| **Frontend** | | | | | +| 2-8 | `lib/types.ts` 작성 | Machine, EquipmentPart, PartCounter 타입 정의 | frontend | write | +| 2-9 | `lib/hooks.ts` 작성 | `useData()` 기본 훅 (기존 패턴 유지: SWR, 30s refresh, 2s dedup). `useMachines(tenantId)`, `useMachine(tenantId, machineId)`, `useEquipmentParts(tenantId, machineId)` 도메인 훅 | frontend | write | +| 2-10 | `components/TopNav.tsx` 작성 | 새 프로젝트용 네비게이션 바. 메뉴: 대시보드, 설비, 검사 템플릿, 검사 실행, 알람. 사용자 정보 + 로그아웃. 알람 뱃지 자리(Phase 6에서 연결) | frontend | write | +| 2-11 | `app/[tenant]/layout.tsx` 작성 | TopNav + AuthGuard 래핑, TenantProvider 설정 | frontend | write | +| 2-12 | `app/[tenant]/page.tsx` — 대시보드 | 설비 목록 카드 뷰 (설비명, 코드, 부품 수, 상태 요약). 클릭 시 설비 상세로 이동 | frontend | write | +| 2-13 | `components/MachineList.tsx` 작성 | 설비 목록 컴포넌트 (검색, 필터). 기존보다 간소화: 건강점수/등급 제거, 부품 수 + 알람 여부만 표시 | frontend | write | +| 2-14 | `app/[tenant]/machines/[id]/page.tsx` — 설비 상세 | 설비 기본 정보 + 부품 탭 (등록/수정/삭제). 부품별: 이름, 카테고리, 수명유형, 한계값, 장착일 표시. 부품 추가 모달 | frontend | write | + +**의존성**: Phase 1 완료 후 +**병렬 가능**: 백엔드(2-1~2-7)와 프론트엔드(2-8~2-14) 병렬. 프론트엔드는 타입 목업으로 먼저 개발 가능 +**예상 소요**: 2일 +**검증 기준**: + +- `pytest tests/test_machines.py tests/test_equipment_parts.py` 전체 통과 +- 브라우저에서 설비 등록 → 부품 추가 → 목록 확인 플로우 + +--- + +### Phase 3: 검사 템플릿 (기준정보) (2~3일) + +| # | 작업 | 상세 | 카테고리 | 스킬 | +| ------------------ | ------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ----- | +| **Backend** | | | | | +| 3-1 | `models.py`에 InspectionTemplate + InspectionTemplateItem 추가 | 위 스키마 참조. Template ↔ Item 1:N cascade. Template.version 관리 로직 | backend | edit | +| 3-2 | `src/api/templates.py` — 템플릿 CRUD API | | backend | write | +| | `GET /api/{tenant_id}/templates` | 목록 (쿼리: subject_type, schedule_type, machine_id 필터). 각 템플릿의 items_count 포함 | | | +| | `POST /api/{tenant_id}/templates` | 생성 (name, subject_type, machine_id, schedule_type, items:[...]). items 배열을 함께 받아 일괄 생성 | | | +| | `GET /api/{tenant_id}/templates/{id}` | 상세 (items 목록 sort_order 순 포함) | | | +| | `PUT /api/{tenant_id}/templates/{id}` | 수정 (name, schedule_type 등). version 자동 증가. 이미 이 템플릿으로 진행된 세션이 있으면 경고 메시지 반환 | | | +| | `DELETE /api/{tenant_id}/templates/{id}` | 비활성화 (is_active=false). 물리 삭제 아님 | | | +| | `POST /api/{tenant_id}/templates/{id}/items` | 항목 추가 (name, category, data_type, unit, spec_min, spec_max, select_options, equipment_part_id, is_required). sort_order 자동 계산 (현재 max + 1) | | | +| | `PUT /api/{tenant_id}/templates/{id}/items/{item_id}` | 항목 수정 | | | +| | `DELETE /api/{tenant_id}/templates/{id}/items/{item_id}` | 항목 삭제 + 나머지 sort_order 재정렬 | | | +| | `PUT /api/{tenant_id}/templates/{id}/items/reorder` | 항목 순서 변경. body: {item_ids: [id1, id2, id3, ...]} 순서대로 sort_order 재할당 | | | +| 3-3 | `main.py`에 templates 라우터 등록 | | backend | edit | +| 3-4 | Alembic 마이그레이션 | `alembic revision --autogenerate -m "add_inspection_template"` | backend | bash | +| 3-5 | `tests/test_templates.py` | | backend | write | +| | 템플릿 생성 (items 포함) + 조회 확인 | | | | +| | 템플릿 수정 시 version 증가 확인 | | | | +| | 항목 추가/수정/삭제/순서변경 테스트 | | | | +| | subject_type 필터 테스트 | | | | +| | equipment_part_id 연결 테스트 | | | | +| | 비활성화(soft delete) 테스트 | | | | +| **Frontend** | | | | | +| 3-6 | `lib/types.ts`에 추가 | InspectionTemplate, InspectionTemplateItem 타입 정의 | frontend | edit | +| 3-7 | `lib/hooks.ts`에 추가 | `useTemplates(tenantId, filters?)`, `useTemplate(tenantId, templateId)` | frontend | edit | +| 3-8 | `app/[tenant]/templates/page.tsx` — 템플릿 목록 | 탭: 설비검사 / 품질검사 (subject_type 필터). 카드 또는 테이블 뷰: 템플릿명, 대상 설비, 주기, 항목수, 버전. "새 템플릿" 버튼 | frontend | write | +| 3-9 | `components/TemplateEditor.tsx` — 핵심 편집기 | | frontend | write | +| | 템플릿 기본정보 입력: 이름, 대상유형(설비/품질), 설비 선택(드롭다운), 검사주기 | | | | +| | 항목 목록: 드래그로 순서 변경 (또는 ↑↓ 버튼) | | | | +| | 항목 추가 폼: 항목명, 카테고리, 데이터유형 선택 → 유형별 조건부 UI | | | | +| | - numeric: 단위, 하한, 상한 입력 | | | | +| | - boolean: 추가 설정 없음 | | | | +| | - text: 추가 설정 없음 | | | | +| | - select: 선택지 목록 편집 (추가/삭제) | | | | +| | 부품 연결: equipment_part_id 선택 (해당 설비의 부품 드롭다운) | | | | +| | 필수 여부 토글 | | | | +| | 항목 삭제 (확인 다이얼로그) | | | | +| 3-10 | `app/[tenant]/templates/new/page.tsx` | TemplateEditor를 사용한 생성 페이지. 저장 시 POST /templates → 목록으로 리다이렉트 | frontend | write | +| 3-11 | `app/[tenant]/templates/[id]/page.tsx` | TemplateEditor를 사용한 편집 페이지. 기존 데이터 로드 → 수정 → PUT /templates/{id} | frontend | write | + +**의존성**: Phase 2 완료 후 (Machine, EquipmentPart 필요) +**병렬 가능**: 백엔드와 프론트엔드 병렬. **Phase 5와도 병렬 가능** (서로 의존 없음) +**예상 소요**: 2~3일 +**검증 기준**: + +- `pytest tests/test_templates.py` 전체 통과 +- 브라우저에서 템플릿 생성 → 항목 추가(각 데이터유형) → 순서변경 → 저장 → 재편집 플로우 + +--- + +### Phase 4: 검사 실행 (세션) (3일) — **핵심 UX, 최고 우선순위** + +> **선배 개발자 경고**: "진행 중인 검사가 초기화되면 시스템 사용률 90% 실패" +> 이 Phase가 전체 프로젝트의 성패를 좌우함 + +| # | 작업 | 상세 | 카테고리 | 스킬 | +| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | -------- | +| **Backend** | | | | | +| 4-1 | `models.py`에 InspectionSession 추가 | 위 스키마 참조. template_snapshot JSON, partial_results JSON, status enum | backend | edit | +| 4-2 | `src/api/sessions.py` — 세션 API | | backend | write | +| | `POST /api/{tenant_id}/sessions` | 세션 시작. body: {template_id}. 로직: (1) 템플릿+항목 조회, (2) JSON 스냅샷 생성, (3) InspectionSession 생성(status='in_progress', template_snapshot=스냅샷, started_at=now). 응답: 세션 ID + 스냅샷 | | | +| | `GET /api/{tenant_id}/sessions` | 목록. 쿼리: status(in_progress/completed/cancelled), template_id, inspector_id. 페이지네이션. 정렬: 최신 순 | | | +| | `GET /api/{tenant_id}/sessions/{id}` | 세션 상세. template_snapshot + partial_results(또는 results) 포함 | | | +| | `PATCH /api/{tenant_id}/sessions/{id}/autosave` | **자동저장**. body: {partial_results: {item_id: {value: any, updated_at: str}}}. status가 'in_progress'일 때만 허용. 부분 업데이트(기존 partial_results에 merge) | | | +| | `POST /api/{tenant_id}/sessions/{id}/complete` | 세션 완료. 로직: (1) partial_results → results로 확정, (2) 필수 항목 미입력 체크, (3) 스펙 이탈 항목 체크 → 알람 생성(Phase 6), (4) status='completed', completed_at=now | | | +| | `POST /api/{tenant_id}/sessions/{id}/cancel` | 세션 취소. status='cancelled' | | | +| | `GET /api/{tenant_id}/sessions/in-progress` | 현재 사용자의 진행중 세션 목록. inspector_id=current_user, status='in_progress'. TopNav에서 진행중 세션 뱃지에 사용 | | | +| 4-3 | 템플릿 스냅샷 로직 | `create_template_snapshot(template_id, db)` 함수. 템플릿 기본정보 + 전체 항목(sort_order 순) JSON 직렬화. 스냅샷에 포함: template.name, template.version, template.schedule_type, items[{id, name, category, data_type, unit, spec_min, spec_max, select_options, equipment_part_id, is_required, sort_order}] | backend | write | +| 4-4 | `main.py`에 sessions 라우터 등록 | | backend | edit | +| 4-5 | Alembic 마이그레이션 | `alembic revision --autogenerate -m "add_inspection_session"` | backend | bash | +| 4-6 | `tests/test_sessions.py` | | backend | write | +| | 세션 시작 → template_snapshot 정확히 저장되는지 확인 | | | | +| | 자동저장(autosave) → partial_results 누적 확인 | | | | +| | 자동저장 후 다시 GET → partial_results 복원 확인 | | | | +| | 세션 완료 → results 확정, status 변경 확인 | | | | +| | 완료 시 필수 항목 미입력 → 에러 반환 확인 | | | | +| | 세션 취소 테스트 | | | | +| | in-progress 목록 → 현재 사용자 것만 반환 확인 | | | | +| | 이미 완료된 세션에 autosave 시도 → 에러 확인 | | | | +| **Frontend** | | | | | +| 4-7 | `lib/types.ts`에 추가 | InspectionSession, SessionStatus, PartialResult 타입 | frontend | edit | +| 4-8 | `lib/hooks.ts`에 추가 | `useSessions(tenantId, filters?)`, `useSession(tenantId, sessionId)`, `useInProgressSessions(tenantId)` | frontend | edit | +| 4-9 | `components/InspectionForm.tsx` — **프로젝트 핵심 컴포넌트** | | frontend | write | +| | **렌더링**: template_snapshot 기반으로 폼 자동 생성. 항목별 입력 UI: | | | | +| | -`numeric`: 숫자 입력 + 단위 표시. spec_min/max 있으면 범위 표시. 범위 초과 시 빨간 테두리 + 경고 아이콘. warning_min/max 있으면 주황색 경고 | | | | +| | -`boolean`: 토글 스위치 (양호/불량 또는 OK/NG) | | | | +| | -`text`: 텍스트 입력 | | | | +| | -`select`: 드롭다운 (select_options 기반) | | | | +| | **자동저장**: 값 변경마다 2초 debounce → PATCH /sessions/{id}/autosave. SWR 낙관적 업데이트(mutate 호출). 저장 상태 표시("저장 중...", "저장됨 HH:MM:SS") | | | | +| | **이탈 방지**: `beforeunload` 이벤트 리스너. Next.js router 이벤트 후킹. 이탈 시도 시 "저장되지 않은 변경사항이 있습니다" 경고 | | | | +| | **진행률**: 입력 완료 항목 수 / 전체 항목 수 표시 (예: "8/12 완료") | | | | +| | **완료 버튼**: 필수 항목 미입력 시 비활성화 + 미입력 항목 하이라이트. 클릭 시 확인 다이얼로그 → POST /sessions/{id}/complete | | | | +| | **카테고리 그룹핑**: 항목을 category별로 그룹핑하여 섹션으로 표시 | | | | +| | **부품 연결 표시**: equipment_part_id가 있는 항목은 부품명 뱃지 표시 | | | | +| | **모바일 반응형**: 태블릿/모바일에서 터치 친화적 UI. 큰 입력 필드, 큰 버튼 | | | | +| 4-9a | ✨**3가지 검사 레이아웃** (inspection_mode별) | | frontend | write | +| | `ChecklistLayout.tsx` — inspection_mode='checklist': 큰 OK/NG 버튼, 스와이프 진행, 모바일 최적화 (스피폭스 순회검사용) | | | | +| | `MeasurementLayout.tsx` — inspection_mode='measurement': 상세 폼, 이미지/X-ray 업로드, 로트번호 입력 (엔키드 품질검사용) | | | | +| | `MonitoringLayout.tsx` — inspection_mode='monitoring': 수치 테이블, 인라인 트렌드 스파크라인 (알펫 라인 모니터링용) | | | | +| | →`next/dynamic`으로 동적 로드. 같은 InspectionForm 컴포넌트를 감싸는 레이아웃 | | | | +| 4-10 | `app/[tenant]/inspections/page.tsx` — 검사 목록 | | frontend | write | +| | 탭: "진행 중" / "완료됨" / "취소됨" (status 필터) | | | | +| | 진행 중 탭: 세션 카드(템플릿명, 검사자, 시작시간, 진행률). 클릭 시 검사 실행 화면으로 이동 → partial_results 자동 복원 | | | | +| | 완료 탭: 세션 카드(템플릿명, 검사자, 완료시간, 결과 요약). 클릭 시 결과 상세 보기 | | | | +| | "새 검사 시작" 버튼 → 템플릿 선택 모달 → POST /sessions → 검사 실행 화면으로 이동 | | | | +| 4-11 | `app/[tenant]/inspections/[id]/page.tsx` — 검사 실행 화면 | | frontend | write | +| | useSession()으로 데이터 로드 | | | | +| | status='in_progress'면 InspectionForm 렌더링 (편집 모드) | | | | +| | status='completed'면 결과 읽기 전용 표시 (스펙 이탈 항목 빨간 표시) | | | | +| | status='cancelled'면 취소됨 안내 | | | | +| 4-12 | 진행중 세션 재진입 플로우 테스트 | 검사 목록에서 진행중 세션 클릭 → partial_results 자동 복원 → 이전에 입력한 값 그대로 표시 → 추가 입력 → 자동저장 → 완료 | frontend | /ui-test | + +**의존성**: Phase 3 완료 후 (InspectionTemplate + Items 필요) +**병렬 가능**: 백엔드와 프론트엔드 병렬 +**예상 소요**: 3일 (InspectionForm이 가장 복잡한 컴포넌트) +**UX 핵심 요구사항 체크리스트**: + +- [ ] 화면 이탈 시 자동저장됨 (절대 초기화 안됨) +- [ ] 진행중 세션 재진입 시 이전 입력값 복원됨 +- [ ] 스펙 초과 시 즉시 시각적 경고 +- [ ] 필수 항목 미입력 시 완료 불가 +- [ ] 모바일/태블릿 터치 친화적 +- [ ] 다중 세션 동시 진행 가능 (작업자가 여러 설비 순회) + +--- + +### Phase 5: 부품 카운터 + 교체 이력 (2~3일) + +> **Phase 3과 병렬 개발 가능** — 서로 의존성 없음 + +| # | 작업 | 상세 | 카테고리 | 스킬 | +| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ----- | +| **Backend** | | | | | +| 5-1 | `models.py`에 PartCounter + PartReplacementLog 추가 | 위 스키마 참조. PartCounter.version으로 optimistic locking. PartCounter ↔ EquipmentPart 1:1 관계 | backend | edit | +| 5-2 | `src/services/counter_service.py` — 카운터 비즈니스 로직 | | backend | write | +| | `increment(db, equipment_part_id, delta, expected_version=None)` | | | | +| | → 카운터 조회, version 체크(expected_version이 주어지면), current_value += delta, lifecycle_pct 재계산, version += 1, last_updated_at = now | | | | +| | → lifecycle_pct >= alarm_threshold 이면 check_alarm_threshold() 호출 | | | | +| | → version 불일치 시 409 Conflict 반환 (optimistic locking) | | | | +| | `reset(db, equipment_part_id, user_id, reason=None, notes=None)` | | | | +| | → 현재 카운터 스냅샷 생성 (counter_at_replacement, lifecycle_pct_at_replacement) | | | | +| | → PartReplacementLog 레코드 생성 | | | | +| | → 카운터 리셋: current_value=0, lifecycle_pct=0, last_reset_at=now, version += 1 | | | | +| | → EquipmentPart.installed_at = now (새 부품 장착) | | | | +| | `recalculate_lifecycle_pct(counter, equipment_part)` | | | | +| | → lifecycle_pct = (current_value / equipment_part.lifecycle_limit) * 100 | | | | +| | → lifecycle_type='date'인 경우: (경과일수 / limit일수) * 100 | | | | +| | `check_alarm_threshold(db, counter, equipment_part)` | | | | +| | → lifecycle_pct >= alarm_threshold 이면 InspectionAlarm 생성 (Phase 6에서 연결) | | | | +| | → 이미 동일 부품에 대해 미확인 알람이 있으면 중복 생성하지 않음 | | | | +| 5-3 | `src/api/counters.py` — 카운터 API | | backend | write | +| | `GET /api/{tenant_id}/machines/{machine_id}/counters` | 설비의 전체 부품 카운터 현황. 응답: [{part_name, category, lifecycle_type, lifecycle_limit, current_value, lifecycle_pct, last_reset_at, alarm_threshold}] | | | +| | `GET /api/{tenant_id}/counters/critical` | 알람 임계값 이상인 카운터 목록 (대시보드 "교체 임박" 섹션용) | | | +| | `POST /api/{tenant_id}/parts/{part_id}/counter/increment` | 카운터 수동 증가. body: {delta, expected_version?}. 응답: 갱신된 카운터 | | | +| | `POST /api/{tenant_id}/parts/{part_id}/replace` | 부품 교체. body: {reason?, notes?}. 로직: counter_service.reset() 호출. 응답: 교체 이력 레코드 + 리셋된 카운터 | | | +| | `GET /api/{tenant_id}/parts/{part_id}/replacement-history` | 교체 이력 목록 (최신 순). 응답: [{replaced_at, replaced_by_name, counter_at_replacement, lifecycle_pct_at_replacement, reason}] | | | +| 5-4 | `main.py`에 counters 라우터 등록 | | backend | edit | +| 5-5 | Alembic 마이그레이션 | `alembic revision --autogenerate -m "add_part_counter_and_replacement_log"` | backend | bash | +| 5-6 | `tests/test_counters.py` | | backend | write | +| | 카운터 증가 → current_value, lifecycle_pct 정확히 계산 | | | | +| | 카운터 증가 → version 증가 확인 | | | | +| | optimistic locking: 잘못된 expected_version → 409 Conflict | | | | +| | 부품 교체(reset) → 카운터 0으로 리셋, 교체 이력 생성 확인 | | | | +| | 교체 이력 → counter_at_replacement 스냅샷 정확한지 확인 | | | | +| | 알람 임계값 도달 시 알람 생성 확인 (Phase 6 연결 후) | | | | +| | critical 카운터 목록 API 테스트 | | | | +| **Frontend** | | | | | +| 5-7 | `lib/types.ts`에 추가 | PartCounter (확장), PartReplacementLog, CounterSummary 타입 | frontend | edit | +| 5-8 | `lib/hooks.ts`에 추가 | `useCounters(tenantId, machineId)`, `useCriticalCounters(tenantId)`, `useReplacementHistory(tenantId, partId)` | frontend | edit | +| 5-9 | `components/PartLifecycleGauge.tsx` — 수명 진행률 게이지 | | frontend | write | +| | 원형 게이지 또는 수평 진행바 | | | | +| | 색상: 0~70% 녹색 (var(--md-success)), 70~90% 주황 (var(--md-warning)), 90~100% 빨강 (var(--md-error)) | | | | +| | 중앙 또는 우측에 현재값/한계값 표시 (예: "8,500 / 10,000") | | | | +| | 수명유형 아이콘 (hours→시계, count→카운터, date→달력) | | | | +| 5-10 | `components/CounterCard.tsx` — 카운터 현황 카드 | | frontend | write | +| | 부품명, 카테고리 뱃지 | | | | +| | PartLifecycleGauge 포함 | | | | +| | "교체" 버튼 (확인 다이얼로그 → POST /parts/{id}/replace) | | | | +| | 마지막 교체일 표시 | | | | +| 5-11 | `app/[tenant]/machines/[id]/page.tsx` 수정 — 카운터 탭 추가 | | frontend | edit | +| | 기존 부품 탭 옆에 "카운터" 탭 추가 | | | | +| | 카운터 탭: CounterCard 목록 (게이지 + 교체 버튼) | | | | +| | 교체 이력 탭: 교체 이력 타임라인 (날짜, 교체자, 교체 시점 카운터값, 사유) | | | | +| 5-12 | `app/[tenant]/page.tsx` 수정 — 대시보드에 "교체 임박 부품" 섹션 | | frontend | edit | +| | useCriticalCounters()로 알람 임계값 이상 카운터 목록 | | | | +| | 카드 형태로 표시: 설비명, 부품명, 수명%, 한계값 | | | | +| | 클릭 시 해당 설비 상세 카운터 탭으로 이동 | | | | + +**의존성**: Phase 2 완료 후 (EquipmentPart 필요). **Phase 3과 병렬 가능!** +**병렬 가능**: 백엔드와 프론트엔드 병렬 +**예상 소요**: 2~3일 +**레이스 컨디션 대응 전략**: + +> **Note**: PostgreSQL MVCC + 이 패턴으로 동시성 문제를 안전하게 처리함. + +```sql +-- PostgreSQL 네이티브 쿼리 +UPDATE part_counters +SET current_value = current_value + :delta, + lifecycle_pct = (current_value + :delta) / :lifecycle_limit * 100, + version = version + 1, + last_updated_at = now() +WHERE id = :counter_id AND version = :expected_version +RETURNING id, current_value, lifecycle_pct, version; +-- RETURNING 절로 업데이트 결과 즉시 반환 (PostgreSQL 전용 기능) +-- 결과가 없으면 → 409 Conflict 반환 +``` + +--- + +### Phase 6: 알람 시스템 (2일) + +| # | 작업 | 상세 | 카테고리 | 스킬 | +| ------------------ | ------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ----- | +| **Backend** | | | | | +| 6-1 | `models.py`에 InspectionAlarm 추가 | 위 스키마 참조. 3가지 alarm_type, 2가지 severity | backend | edit | +| 6-2 | `src/services/alarm_service.py` — 알람 서비스 | | backend | write | +| | `create_alarm(db, tenant_id, alarm_type, severity, title, message, machine_id=None, equipment_part_id=None, session_id=None)` | 알람 생성. 동일 부품/세션에 미확인 중복 알람이 있으면 생성 안 함 | | | +| | `check_part_lifecycle_alarms(db, tenant_id)` | 전체 부품 카운터 순회 → lifecycle_pct >= alarm_threshold인 것 찾아 알람 생성. 배치용 (스케줄러 또는 API 호출) | | | +| | `check_overdue_inspections(db, tenant_id)` | schedule_type별 마지막 세션 완료 시간 체크 → 기한 초과 시 알람 생성. daily: 오늘 완료 세션 없으면, weekly: 이번 주 완료 세션 없으면 등 | | | +| | `create_spec_violation_alarm(db, session, item_name, value, spec_min, spec_max)` | 검사 완료 시 스펙 이탈 항목에 대해 호출. severity: spec 초과 정도에 따라 warning/critical | | | +| | `get_alarm_summary(db, tenant_id)` | 미확인 알람 타입별 개수. 응답: {part_lifecycle: 3, inspection_overdue: 1, spec_violation: 2, total: 6} | | | +| 6-3 | `src/api/alarms.py` — 알람 API | | backend | write | +| | `GET /api/{tenant_id}/alarms` | 알람 목록. 쿼리: alarm_type, severity, is_acknowledged, machine_id. 페이지네이션. 최신 순 | | | +| | `GET /api/{tenant_id}/alarms/summary` | 미확인 알람 개수 요약 (TopNav 뱃지용). 응답: {total: N, by_type: {...}} | | | +| | `POST /api/{tenant_id}/alarms/{id}/acknowledge` | 알람 확인 처리. acknowledged_by=current_user, acknowledged_at=now | | | +| | `POST /api/{tenant_id}/alarms/acknowledge-bulk` | 다중 알람 일괄 확인. body: {alarm_ids: [...]} | | | +| 6-4 | Phase 4 연결: 세션 완료 로직에 스펙 이탈 알람 추가 | `sessions.py`의 complete 엔드포인트에서 각 결과값을 template_snapshot의 spec_min/max와 비교. 이탈 항목마다 `alarm_service.create_spec_violation_alarm()` 호출 | backend | edit | +| 6-5 | Phase 5 연결: 카운터 서비스에 수명 알람 추가 | `counter_service.py`의 `check_alarm_threshold()`에서 `alarm_service.create_alarm()` 호출. alarm_type='part_lifecycle', severity는 lifecycle_pct >= 100이면 'critical', 그 외 'warning' | backend | edit | +| 6-6 | `main.py`에 alarms 라우터 등록 | | backend | edit | +| 6-7 | Alembic 마이그레이션 | `alembic revision --autogenerate -m "add_inspection_alarm"` | backend | bash | +| 6-8 | `tests/test_alarms.py` | | backend | write | +| | 알람 수동 생성 + 조회 테스트 | | | | +| | 중복 알람 방지 테스트 (같은 부품에 미확인 알람 있으면 중복 생성 안 됨) | | | | +| | 알람 확인(acknowledge) 테스트 | | | | +| | 일괄 확인 테스트 | | | | +| | 알람 요약(summary) 테스트 | | | | +| | 필터(alarm_type, severity, is_acknowledged) 테스트 | | | | +| | 카운터 증가 → 임계값 도달 → 알람 자동 생성 E2E | | | | +| | 세션 완료 → 스펙 이탈 → 알람 자동 생성 E2E | | | | +| **Frontend** | | | | | +| 6-9 | `lib/types.ts`에 추가 | InspectionAlarm, AlarmType, AlarmSeverity, AlarmSummary 타입 | frontend | edit | +| 6-10 | `lib/hooks.ts`에 추가 | `useAlarms(tenantId, filters?)`, `useAlarmSummary(tenantId)` | frontend | edit | +| 6-11 | `components/AlarmBadge.tsx` — TopNav 알람 뱃지 | | frontend | write | +| | useAlarmSummary()로 미확인 알람 총 개수 조회 | | | | +| | 0이면 표시 안 함, 1 이상이면 빨간 원형 뱃지로 개수 표시 | | | | +| | 클릭 시 /alarms 페이지로 이동 | | | | +| | 30초 자동 갱신 (SWR refreshInterval) | | | | +| 6-12 | `app/[tenant]/alarms/page.tsx` — 알람 목록 | | frontend | write | +| | 필터 바: 알람 유형(부품수명/검사미실시/스펙이탈), 심각도(경고/긴급), 확인여부(미확인/확인) | | | | +| | 알람 카드 목록: 아이콘(유형별), 제목, 메시지, 생성시간, 설비명, 부품명 | | | | +| | 미확인 알람: "확인" 버튼 (POST /alarms/{id}/acknowledge) | | | | +| | 일괄 확인: 체크박스 선택 → "선택 확인" 버튼 | | | | +| | severity에 따른 색상: warning=주황, critical=빨강 | | | | +| 6-13 | TopNav에 AlarmBadge 통합 | TopNav.tsx에 AlarmBadge 컴포넌트 추가 | frontend | edit | + +**의존성**: Phase 4(세션 완료 시 스펙 이탈) + Phase 5(카운터 임계값) 완료 후 6-4, 6-5 연결. 단, 6-1~6-3(알람 기본 CRUD)은 Phase 4/5와 병렬 가능 +**병렬 가능**: 알람 모델/API 기본 구조는 Phase 3 이후 바로 시작 가능. 연결(6-4, 6-5)만 Phase 4/5 이후 +**예상 소요**: 2일 + +--- + +### Phase 7: 통합 테스트 + 마무리 (1일) + +| # | 작업 | 상세 | 카테고리 | 스킬 | +| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------- | -------- | -------- | +| 7-1 | 전체 pytest 실행 + 실패 수정 | `pytest tests/ -v` 전체 통과 확인 | test | /test | +| 7-2 | E2E 플로우 테스트 (브라우저) | | test | /ui-test | +| | 플로우 1: 로그인 → 설비 등록 → 부품 등록 → 카운터 확인 | | | | +| | 플로우 2: 템플릿 생성 (항목 추가, 부품 연결) → 저장 → 재편집 | | | | +| | 플로우 3: 검사 시작 → 항목 입력 → 다른 페이지 이동 → 돌아옴 → 이전 입력 복원 → 완료 | | | | +| | 플로우 4: 카운터 증가 → 임계값 도달 → 알람 생성 → 알람 확인 | | | | +| | 플로우 5: 부품 교체 → 카운터 리셋 → 교체 이력 확인 | | | | +| 7-3 | API 문서 검증 | FastAPI 자동생성 /docs (Swagger) 확인. 모든 엔드포인트 정상 표시, 요청/응답 스키마 정확한지 | test | /ui-test | +| 7-4 | 모바일 반응형 확인 | 태블릿 뷰포트(768px)에서 검사 실행 화면 사용성 확인. 터치 타겟 충분한 크기, 스크롤 적절, 입력 편의성 | test | /ui-test | +| 7-5 | 시드 데이터 스크립트 | `scripts/seed.py` — 3개 테넌트 시드 데이터: | backend | write | +| | **스피폭스**: 설비 5대 (프레스 3, 건조 1, 세척 1), 부품 각 3~5개 (금형, 팁, 센서), 템플릿 3개 (일일 checklist, 주간 measurement, 월간), 샘플 카운터 값 | | | | +| | **엔키드**: 설비 3대 (Toyo 650T, Toshiba 350T, Toyo 200T), 부품 각 2~3개 (금형, 플런저팁, 슬리브), 템플릿 2개 (일일 checklist, 품질검사 measurement + X-ray select 항목) | | | | +| | **알펫**: 설비 4대 (전처리, 라미네이팅, 코팅, 리코일러), 부품 각 2~3개 (히터 소자, 화학약품, 롤), 템플릿 2개 (일일 monitoring, 주간 measurement) | | | | +| 7-6 | 정리 | 사용하지 않는 import 제거, 코드 포매팅, 주석 정리 | cleanup | edit | + +**의존성**: Phase 6 완료 후 +**예상 소요**: 1일 + +--- + +## 3. Task 의존성 그래프 + +``` +Phase 0 (부트스트래핑, 0.5일) + │ + ▼ +Phase 1 (인증 + DB, 1.5일) + │ + ▼ +Phase 2 (설비 + 부품, 2일) + │ + ├──────────────────────────────┐ + ▼ ▼ +Phase 3 (검사 템플릿, 2~3일) Phase 5 (카운터 + 교체, 2~3일) + │ │ + ▼ │ ← 이 두 Phase는 서로 의존 없음! +Phase 4 (검사 실행, 3일) │ 병렬 개발로 ~2일 단축 가능 + │ │ + ├──────────────────────────────┘ + ▼ +Phase 6 (알람, 2일) + │ ← Phase 4의 세션완료 + Phase 5의 카운터 서비스에 알람 연결 + ▼ +Phase 7 (통합 테스트, 1일) +``` + +### 병렬 실행 최적화 요약 + +| 병렬 기회 | 설명 | 절약 시간 | +| ----------------------------- | ---------------------------------------------------------------- | ------------------- | +| **Phase 3 + Phase 5** | 검사 템플릿과 카운터 시스템은 서로 의존 없음 | ~2일 | +| **각 Phase 내 BE + FE** | 백엔드와 프론트엔드 항상 병렬 가능 (타입 목업 활용) | 각 Phase에서 ~0.5일 | +| **Phase 6 기본 CRUD** | 알람 모델/API 기본 구조는 Phase 2 이후 시작 가능 (연결만 나중에) | ~0.5일 | + +--- + +## 4. 예상 일정 요약 + +| Phase | 내용 | 순차 시 | 병렬 시 | 누적 (병렬) | +| -------------- | ---------------------------------------------- | -------------- | ---------------------- | ------------------ | +| 0 | 프로젝트 부트스트래핑 | 0.5일 | 0.5일 | 0.5일 | +| 1 | 인증 + DB 기반 | 1.5일 | 1.5일 | 2일 | +| 2 | 설비 + 부품 관리 (+counter_source) | 2.5일 | 2.5일 | 4.5일 | +| 3 | 검사 템플릿 (+inspection_mode, warning, trend) | 3.5일 | 3.5일 (Phase 5와 병렬) | 8일 | +| 5 | 카운터 + 교체 이력 | 3일 | — (Phase 3과 동시) | — | +| 4 | 검사 실행 (+3개 레이아웃, lot_number) | 4일 | 4일 | 12일 | +| 6 | 알람 시스템 (+trend_warning) | 2.5일 | 2.5일 | 14.5일 | +| 7 | 통합 테스트 + 멀티테넌트 시드 | 1.5일 | 1.5일 | 16일 | +| **합계** | | **19일** | **16일** | **약 3.2주** | + +> 기존 13일 → 16일 (+3일): 3개 고객사 멀티테넌트 지원을 위한 추가 작업 +> 상세 변경 내역: 섹션 9 "3개 고객사 멀티테넌트 전략" 참조 + +--- + +## 5. 각 작업의 Agent 위임 추천 + +| 카테고리 | 추천 방식 | 사용 스킬/도구 | 비고 | +| ----------------------- | ----------------------------------- | ---------------------------- | ------------------------------------ | +| 프로젝트 셋업 (Phase 0) | 직접 실행 | bash + write | 디렉토리 생성, 초기화, npm init | +| DB 모델 작성 | 직접 실행 | write | SQLAlchemy 모델 정의 | +| API 라우터 작성 | 직접 실행 | write | FastAPI 엔드포인트, Pydantic 스키마 | +| 서비스 로직 | 직접 실행 | write | counter_service.py, alarm_service.py | +| 테스트 작성 + 실행 | 직접 작성 →`/test` 실행 | write + /test | pytest | +| Alembic 마이그레이션 | 직접 실행 | bash | alembic revision --autogenerate | +| 프론트엔드 페이지 | `/frontend-design` 스킬 | /frontend-design | 레이아웃 + Material Design 3 스타일 | +| InspectionForm (핵심) | `/frontend-design` + `/ui-test` | /frontend-design → /ui-test | 자동저장, debounce, 반응형, 이탈방지 | +| TemplateEditor | `/frontend-design` | /frontend-design | 드래그 순서변경, 조건부 UI | +| PartLifecycleGauge | `/frontend-design` | /frontend-design | 원형 게이지, 색상 전환 | +| 서버 실행 | `/serve` 스킬 | /serve | Podman 기반 | +| UI 검증 | `/ui-test` 스킬 | /ui-test | Playwright 브라우저 테스트 | +| 커밋 | `/commit` 스킬 | /commit | 각 Phase 완료 시 | +| 코드 리뷰 | `/review` 스킬 | /review | Phase 4, 6 완료 후 | + +--- + +## 6. 기존 코드 참고 가이드 + +> ⚠️ 기존 factoryOps 프로젝트는 **참고만** 합니다. 코드를 복사하지 않고, 패턴과 로직만 발췌하여 새로 작성합니다. + +### 참고할 패턴 (기존 코드에서 발췌할 것) + +| 영역 | 참고 파일 (기존) | 발췌할 패턴 | +| ------------- | ------------------------------------ | ----------------------------------------------------------- | +| JWT 인증 | `src/auth/jwt_handler.py` | HS256 알고리즘, 24h 만료, 페이로드 구조 | +| 비밀번호 해싱 | `src/auth/password.py` | bcrypt 해싱 로직 | +| API 구조 | `src/auth/router.py` | FastAPI 라우터 패턴 (login, /me 등) | +| 테넌트 격리 | `src/tenant/manager.py` | tenant_id 기반 데이터 격리 패턴 | +| SWR 패턴 | `dashboard/lib/api.ts` | `fetcher` + `useData()` 패턴 (30s refresh, 2s dedup) | +| URL 헬퍼 | `dashboard/lib/api.ts` | `getTenantUrl()` (`/api/{tenant_id}/...` 패턴) | +| CSS 변수 | `dashboard/app/globals.css` | Material Design 3 CSS 변수 시스템 | +| React Context | `dashboard/lib/auth-context.tsx` | AuthContext, TenantContext, ToastContext 패턴 | +| 어댑터 | `src/adapters/speedfox_adapter.py` | BaseAdapter 인터페이스, 데이터 변환 패턴 | + +### 새로 작성할 때 주의사항 + +1. **PostgreSQL 전용**: `aiosqlite`, `sqlite3` 관련 코드 일체 불필요 +2. **Async 우선**: SQLAlchemy async session, asyncpg 드라이버 사용 +3. **간소화**: 기존의 Score/Action/Stats/Ontology 등 불필요한 모델 포함하지 않음 +4. **main.py 목표 60~80줄**: 최소 구성 (app 생성, CORS, routers, init_db) + +--- + +## 7. 기술 의존성 (requirements.txt / package.json) + +### Backend (Python) + +``` +# Core +fastapi==0.115.0 +uvicorn[standard]==0.30.0 +python-multipart==0.0.9 + +# Database (PostgreSQL 전용, async) +sqlalchemy[asyncio]==2.0.36 +asyncpg==0.30.0 # PostgreSQL async 드라이버 +alembic==1.14.0 + +# Auth +PyJWT==2.9.0 +bcrypt==4.2.0 + +# Testing +pytest==8.2.2 +pytest-asyncio==0.24.0 # async 테스트 지원 +httpx==0.27.0 # async TestClient +``` + +> ⚠️ psycopg2-binary 대신 **asyncpg** 사용 — FastAPI의 async 핸들러와 자연스럽게 결합. +> SQLite 관련 패키지(aiosqlite 등) 불필요. + +### Frontend (Node.js) + +```json +{ + "dependencies": { + "next": "16.1.6", + "react": "19.2.3", + "react-dom": "19.2.3", + "swr": "2.3.8", + "clsx": "2.1.1" + }, + "devDependencies": { + "typescript": "5.x", + "@types/react": "19.x", + "@types/node": "22.x" + } +} +``` + +--- + +## 8. API 엔드포인트 전체 목록 + +### 인증 (Phase 1) + +``` +POST /api/auth/login # 로그인 +POST /api/auth/logout # 로그아웃 +GET /api/auth/me # 현재 사용자 정보 +POST /api/admin/users # 사용자 생성 (superadmin) +GET /api/admin/users # 사용자 목록 (superadmin) +``` + +### 설비 (Phase 2) + +``` +GET /api/{tenant_id}/machines # 설비 목록 +POST /api/{tenant_id}/machines # 설비 등록 +GET /api/{tenant_id}/machines/{id} # 설비 상세 +PUT /api/{tenant_id}/machines/{id} # 설비 수정 +DELETE /api/{tenant_id}/machines/{id} # 설비 삭제 +``` + +### 설비 부품 (Phase 2) + +``` +GET /api/{tenant_id}/machines/{machine_id}/parts # 부품 목록 +POST /api/{tenant_id}/machines/{machine_id}/parts # 부품 등록 (+카운터 자동생성) +GET /api/{tenant_id}/parts/{id} # 부품 상세 +PUT /api/{tenant_id}/parts/{id} # 부품 수정 +DELETE /api/{tenant_id}/parts/{id} # 부품 비활성화 +``` + +### 검사 템플릿 (Phase 3) + +``` +GET /api/{tenant_id}/templates # 템플릿 목록 (subject_type 필터) +POST /api/{tenant_id}/templates # 템플릿 생성 (항목 포함) +GET /api/{tenant_id}/templates/{id} # 템플릿 상세 (항목 포함) +PUT /api/{tenant_id}/templates/{id} # 템플릿 수정 (버전 증가) +DELETE /api/{tenant_id}/templates/{id} # 템플릿 비활성화 +POST /api/{tenant_id}/templates/{id}/items # 항목 추가 +PUT /api/{tenant_id}/templates/{id}/items/{item_id} # 항목 수정 +DELETE /api/{tenant_id}/templates/{id}/items/{item_id} # 항목 삭제 +PUT /api/{tenant_id}/templates/{id}/items/reorder # 항목 순서 변경 +``` + +### 검사 세션 (Phase 4) + +``` +POST /api/{tenant_id}/sessions # 세션 시작 (템플릿 스냅샷 저장) +GET /api/{tenant_id}/sessions # 세션 목록 (status 필터) +GET /api/{tenant_id}/sessions/in-progress # 현재 사용자 진행중 세션 +GET /api/{tenant_id}/sessions/{id} # 세션 상세 +PATCH /api/{tenant_id}/sessions/{id}/autosave # 자동저장 (partial_results) +POST /api/{tenant_id}/sessions/{id}/complete # 세션 완료 +POST /api/{tenant_id}/sessions/{id}/cancel # 세션 취소 +``` + +### 부품 카운터 (Phase 5) + +``` +GET /api/{tenant_id}/machines/{machine_id}/counters # 설비 카운터 현황 +GET /api/{tenant_id}/counters/critical # 교체 임박 카운터 목록 +POST /api/{tenant_id}/parts/{part_id}/counter/increment # 카운터 증가 +POST /api/{tenant_id}/parts/{part_id}/replace # 부품 교체 (리셋+이력) +GET /api/{tenant_id}/parts/{part_id}/replacement-history # 교체 이력 +``` + +### 알람 (Phase 6) + +``` +GET /api/{tenant_id}/alarms # 알람 목록 (type/severity/acknowledged 필터) +GET /api/{tenant_id}/alarms/summary # 미확인 알람 개수 요약 +POST /api/{tenant_id}/alarms/{id}/acknowledge # 알람 확인 +POST /api/{tenant_id}/alarms/acknowledge-bulk # 다중 알람 일괄 확인 +``` + +**총 엔드포인트 수: 32개** + +--- + +## 9. 3개 고객사 멀티테넌트 전략 (2026-02-10 추가) + +> 상세 분석: `planning/integrated-analysis.md` 참조 + +### 9.1 업체별 설비검사/품질검사 로직 차이 → 해결 전략 요약 + +| 차이 영역 | 해결 메커니즘 | 코드 분기 여부 | +| -------------------------------------- | ------------------------------------------------------------ | :--------------: | +| 카운터 갱신 (PLC vs 시간 vs 날짜) | `EquipmentPart.lifecycle_type` + `counter_source` | ❌ TYPE 분기 | +| 품질 판정 (이진 vs 불량코드 vs 수치) | `InspectionTemplateItem.data_type` + `spec_min/max` | ❌ TYPE 분기 | +| 알람 유형 (임계값 vs 추세) | `InspectionAlarm.alarm_type` (4종) + 템플릿 설정 | ❌ TYPE 분기 | +| 검사 UX (빠른체크 vs 정밀 vs 모니터링) | `InspectionTemplate.inspection_mode` (3종) | ❌ TYPE 분기 | +| PLC 데이터 형식 | 어댑터 계층 (SpeedfoxAdapter / EnkiedAdapter / AlpetAdapter) | ✅ (설계상 분리) | +| 테넌트 정책 (동시세션수, 첨부파일 등) | `Tenant.workflow_config` JSON | ❌ 설정값 | + +### 9.2 스키마 변경 요약 (기존 대비) + +**추가된 컬럼 (7개)**: + +1. `EquipmentPart.counter_source` VARCHAR(20) — 카운터 갱신 방식 +2. `InspectionTemplate.inspection_mode` VARCHAR(20) — 검사 UX 모드 +3. `InspectionTemplateItem.warning_min` FLOAT — 트렌드 경고 소프트 하한 +4. `InspectionTemplateItem.warning_max` FLOAT — 트렌드 경고 소프트 상한 +5. `InspectionTemplateItem.trend_window` INTEGER — 연속 N회 경고구간 체크 +6. `InspectionSession.lot_number` VARCHAR(100) — 로트/시리얼 (IATF 추적성) +7. `InspectionAlarm.alarm_type` — `trend_warning` 값 추가 (기존 3종 → 4종) + +**추가된 프론트엔드 레이아웃 (3개)**: + +``` +dashboard/app/[tenant]/inspections/[id]/_layouts/ + ChecklistLayout.tsx # inspection_mode='checklist' + MeasurementLayout.tsx # inspection_mode='measurement' + MonitoringLayout.tsx # inspection_mode='monitoring' +``` + +**추가된 어댑터 (1개)**: + +- `src/adapters/alpet_adapter.py` — 알펫 연속공정 PLC 데이터 변환 + +### 9.3 구현 순서 (기존 Phase에 통합) + +| Phase | 기존 내용 | 추가 작업 | 추가 소요 | +| -------------- | ----------- | ---------------------------------------------------------------- | -------------- | +| Phase 2 | 설비 + 부품 | `counter_source` 컬럼 추가, `auto_time` 계산 로직 | +0.5일 | +| Phase 3 | 검사 템플릿 | `inspection_mode` 컬럼 + `warning_min/max/trend_window` 추가 | +0.5일 | +| Phase 4 | 검사 세션 | `lot_number` 컬럼, 3개 레이아웃 (ChecklistLayout 등) | +1일 | +| Phase 6 | 알람 | `trend_warning` 알람 유형 + 트렌드 평가 로직 | +0.5일 | +| Phase 7 | 통합 테스트 | 엔키드/알펫 시드 데이터, 멀티테넌트 E2E 테스트 | +0.5일 | +| **합계** | | | **+3일** | + +**수정된 일정**: 기존 13일 → **16일 (약 3.2주)** + +### 9.4 주의사항 + +1. **알펫 설비 계층**: 알펫의 Machine은 개별 기계가 아닌 **라인 구간** (전처리존, 라미네이팅 스테이션 등). Machine 모델로 표현 가능하나, 이름 규칙으로 구분 (예: "ALPET-LINE1-PRETREAT", "ALPET-LINE1-LAMI") +2. **엔키드 로트 추적**: IATF 16949은 검사 결과 → 특정 로트/시리얼 연결 필수. `lot_number` 필드로 대응 +3. **스피폭스 대규모 알람 성능**: 304대 × 부품 → 알람 평가는 **검사 제출/카운터 갱신 시점에만** (PLC 원시 데이터 수신 시에는 평가 안 함) + +--- + +## 10. 미결 질문 (구현 전 확인 필요) + +1. **병렬 개발 전략**: Phase 3(검사 템플릿)과 Phase 5(카운터)를 병렬로 진행할지, 순차로 할지? + + - 병렬: ~2일 단축 (16일) + - 순차: 더 안전하지만 더 오래 걸림 (18.5일) +2. **품질검사 범위**: 이번 구현에서 `subject_type='product'`도 실제 UI/로직을 만들지? + + - 추천: equipment만 우선 구현. 모델에 subject_type 필드만 두고, product 관련 UI/로직은 다음 Phase에서 확장 + - 엔키드가 product 검사 필요하므로 Phase 1+ 이후 확장 예정 + - 둘 다 하면 2~3일 추가 +3. **검사 미실시(overdue) 알람의 기준**: 스케줄러(cron)로 주기적 체크할지, API 호출 시 체크할지? + + - 옵션 A: 별도 스케줄러 (APScheduler 등) — 정확하지만 인프라 복잡도 증가 + - 옵션 B: 대시보드 로딩 시 체크 — 단순하지만 대시보드 접속 안 하면 알람 안 생김 + - 옵션 C: 알람 목록 API 호출 시 체크 — 절충안 + - 추천: Phase 1에서는 옵션 C (on_access), 향후 스케줄러로 확장 +4. **알펫 PLC 태그 리스트**: AlpetAdapter 구현 전 알펫에서 PLC 태그 사양서 수집 필요 +5. **엔키드 X-ray 이미지 저장소**: 첨부파일 스토리지 설계 (S3? 로컬? 용량 예측 필요) +6. **연속공정 세션 단위**: 알펫의 검사 세션 = 코일 1롤? 시간 구간? 교대 1회? diff --git a/planning/findings.md b/planning/findings.md new file mode 100644 index 0000000..97e0bc4 --- /dev/null +++ b/planning/findings.md @@ -0,0 +1,7 @@ +# Findings + +## 기존 코드 참고 사항 +- 기존 프로젝트: `/Users/johngreen/Dev/factoryops_backup/` +- Auth 패턴: JWT HS256, 24h 만료, bcrypt, role-based (superadmin/tenant_admin/user) +- Frontend 패턴: SWR fetcher, AuthContext/TenantContext/ToastContext, Material Design 3 +- DB: PostgreSQL 전용 (asyncpg), SQLAlchemy async diff --git a/planning/integrated-analysis.md b/planning/integrated-analysis.md new file mode 100644 index 0000000..c91843d --- /dev/null +++ b/planning/integrated-analysis.md @@ -0,0 +1,478 @@ +# 3개 고객사 통합 비교분석서 + +> 작성일: 2026-02-10 +> 대상: 스피폭스(SpiFox), 엔키드(Enkid), 알펫(Alpet) +> 목적: FactoryOps v2 설비검사/품질검사 시스템의 공통 아키텍처 도출 및 업체별 차이점 해결 전략 수립 +> ⚠️ **새 프로젝트 (PostgreSQL 전용)** — 기존 factoryOps 코드베이스는 참고만. asyncpg + SQLAlchemy async 사용. + +--- + +## 1. 고객사 개요 비교 + +| 항목 | 스피폭스 (SpiFox) | 엔키드 (Enkid) | 알펫 (Alpet) | +|------|-------------------|----------------|--------------| +| **업종** | 알루미늄 전해 콘덴서 케이스 | 자동차 부품 (다이캐스팅) | PET필름 라미네이팅 알루미늄판 | +| **공정 유형** | 이산 공정 (프레스) | 이산 공정 (다이캐스팅) | **연속 공정** (코일→코일) | +| **설비 수** | 304대 (대규모) | 7대 (소규모) | ~15대 (중규모, 연속라인) | +| **주요 설비** | CASE프레스 186, CUP프레스 32, 머신비전 30→80 | Toyo/Toshiba 다이캐스팅기 200T~650T | 언코일러, 전처리, 라미네이팅, 코팅, 리코일러 | +| **PLC 파라미터** | 60+ (온도11, 압력5, 시간20+, 속도10) | 26+ (온도12, 압력6, 속도4, 전류4) | 41차원 (온도31, 농도6, 전류2, 온습도6) | +| **핵심 관리 대상** | 금형 타발수, 비전검사 | 금형 마모, X-ray/CT 검사 | 온도 프로파일, 밀착력 | +| **예산** | 12억/24개월 | 3.87억/11개월 | 4억/11개월 | +| **품질 기준** | 불량률 1.51%→1.00% | IATF 16949 (자동차) | 밀착력 ≥ 5N, 두께 균일도 | + +--- + +## 2. 설비검사 비교 + +### 2.1 검사 대상 및 주기 + +| 항목 | 스피폭스 | 엔키드 | 알펫 | +|------|---------|--------|------| +| **일일검사** | 프레스 186대 순회 (작업자 10+대/교대) | 7대 전수 (작업자 1~2인) | 연속라인 시작 전 점검 | +| **주간검사** | 금형 상태, 윤활, 건조/세척 설비 | 금형 마모 측정, 유압 점검 | 화학 용액 농도, 필터 상태 | +| **월간검사** | 열처리기, 자동창고 | 3차원측정기 교정, 냉각수 | 히터 소자, 퀜칭 탱크 | +| **수시검사** | 금형 교체 후, 비전 장비 캘리브레이션 | 불량 발생 시, 금형 교체 후 | 제품 전환 시, 밀착력 이상 시 | + +### 2.2 부품 수명 관리 방식 + +| 항목 | 스피폭스 | 엔키드 | 알펫 | +|------|---------|--------|------| +| **주요 방식** | 타발수 (count) | 타발수 (count) + 시간 (hours) | 시간 (hours) + 날짜 (date) | +| **관리 부품 예시** | 상부금형 10,000타, 하부금형 8,000타, 플런저팁 5,000타 | 금형 15,000타, 플런저팁 3,000타, 슬리브 2,000시간 | 히터 소자 5,000시간, 화학약품 30일, 라미네이팅 롤 3,000시간 | +| **카운터 소스** | PLC shotno (자동) | PLC shots (자동) + 가동시간 (자동) | 가동시간 (자동) + 달력 (date 기반) | +| **교체 트리거** | 9,000타 알람 → 10,000타 교체 | 알람 + 측정 확인 | 알람 + 밀착력 테스트 확인 | +| **교체 후 리셋** | 카운터 0 리셋 | 카운터 0 리셋 | 카운터 0 리셋 or 날짜 갱신 | + +### 2.3 검사 항목 유형 + +| data_type | 스피폭스 예시 | 엔키드 예시 | 알펫 예시 | +|-----------|-------------|------------|----------| +| **numeric** | 유압 압력 (140~160 bar), 금형 온도 (180~220℃) | 사출 압력 (80~120 MPa), 금형 온도 (200~280℃) | 라미네이팅 온도 (240~260℃), 밀착력 (≥5.0 N) | +| **boolean** | 윤활 상태 OK/NG, 비전 캘리브레이션 OK/NG | 냉각수 순환 OK/NG, 유압 누유 OK/NG | 온도 프로파일 정상/이상, 필터 상태 OK/NG | +| **text** | 외관 메모, 이상 발견 내용 | 불량 상세 기술, X-ray 판독 소견 | 코팅 상태 관찰 내용 | +| **select** | 금형 상태 [양호/주의/교체필요] | 표면 품질 등급 [A/B/C/D] | 밀착 등급 [우수/양호/불량] | + +--- + +## 3. 품질검사 비교 + +### 3.1 검사 방식 + +| 항목 | 스피폭스 | 엔키드 | 알펫 | +|------|---------|--------|------| +| **주요 검사** | 머신비전 자동검사 (CNN) | X-ray, 3D CT, 치수 측정, 중량 | 밀착력, 두께, 표면 외관 | +| **검사 단위** | 개별 제품 (전수검사) | 개별 제품 (샘플 + 전수) | **연속 코일** (구간별 샘플링) | +| **판정 방식** | OK/NG (이진) | OK/NG + 불량 코드 6종 + 신뢰도 | 수치 기반 (밀착력 N 값) | +| **불량 유형** | 버, 찌그러짐, 스크래치 | 기포, 미성형, 플래시, 수축, 표면, 치수/중량 | 밀착 불량, 두께 편차, 코팅 불균일 | +| **자동화 수준** | 높음 (비전 80대 확장 예정) | 중간 (X-ray AI 도입 예정) | 낮음 (수동 샘플링 + 센서 모니터링) | + +### 3.2 품질 데이터 흐름 + +**스피폭스**: +``` +프레스 생산 → 머신비전 자동검사 → OK/NG 즉시 판정 → 불량 통계 집계 + → NG 이미지 저장 +``` + +**엔키드**: +``` +다이캐스팅 생산 → 외관검사 (Visual) → X-ray/CT 검사 → 치수측정 → 중량측정 + → 불량코드 + 신뢰도 → 종합 판정 + → 이미지/X-ray 첨부 +``` + +**알펫**: +``` +연속생산 라인 → 실시간 센서 모니터링 (온도, 두께) + → 정기 샘플 채취 → 밀착력 테스트 (파괴 시험) + → 구간별 합격/불합격 판정 +``` + +--- + +## 4. 핵심 차이점 요약 + +### 4.1 공통점 (80% — 단일 엔진으로 처리 가능) + +1. **기준정보 구조 동일**: 설비 → 부품 → 검사템플릿 → 검사항목 (선배 개발자 원칙 그대로) +2. **검사항목 유형 동일**: numeric, boolean, text, select 4가지 data_type +3. **부품 수명 관리 원리 동일**: lifecycle_type + lifecycle_limit + 알람 임계값 +4. **검사 워크플로우 동일**: 템플릿 선택 → 세션 시작 → 항목 입력 (자동저장) → 완료 +5. **알람 유형 동일**: 부품 수명 초과, 검사 미실시, 스펙 이탈 +6. **멀티테넌트 격리**: tenant_id 기반 데이터 격리 + +### 4.2 차이점 (20% — 설정/어댑터로 흡수) + +| 차이 영역 | 설명 | 해결 방식 | +|-----------|------|----------| +| **① 카운터 소스** | PLC shotno vs 가동시간 vs 달력 | `lifecycle_type` 필드 (`count`/`hours`/`date`)로 이미 설계됨 | +| **② 카운터 갱신 방식** | PLC 자동 vs 수동 입력 | `counter_source` 설정: `auto_plc`/`auto_production`/`manual` | +| **③ 품질 판정 복잡도** | 이진(OK/NG) vs 다중 불량코드+신뢰도 vs 수치기반 | 검사 결과는 모두 `InspectionSession.results` JSON에 저장. 판정 로직은 템플릿의 `spec_min`/`spec_max`로 통일 | +| **④ 검사 빈도/규모** | 304대 순회 vs 7대 정밀 vs 연속라인 모니터링 | UX 차이일 뿐, 데이터 모델은 동일. 대시보드 뷰만 다르게 구성 | +| **⑤ 첨부파일** | 비전 이미지 vs X-ray/CT vs 없음 | `InspectionSession.results` 내 `attachments` JSON 필드 (optional) | +| **⑥ 연속공정 특수성** | 알펫만 코일 단위 (개별 제품이 아님) | `subject_type='product'` 시 `product_code`에 코일 번호 사용 | + +--- + +## 5. 업체별 설비검사/품질검사 로직 차이점 해결 전략 + +> **핵심 원칙**: 코드 분기(if spifox / elif enkid / elif alpet) 최소화. +> **설정(Configuration)과 템플릿(Template)으로 차이를 흡수한다.** + +### 5.1 전략 개요: 3-Layer 분리 + +``` +┌─────────────────────────────────────────────────────┐ +│ Layer 1: 공통 엔진 (Common Engine) │ +│ ─ 모든 테넌트가 공유하는 코드 │ +│ ─ InspectionTemplate, InspectionSession, PartCounter │ +│ ─ 알람 서비스, 자동저장, 검사 완료 로직 │ +│ ─ 변경: 거의 없음 │ +├─────────────────────────────────────────────────────┤ +│ Layer 2: 테넌트 설정 (Tenant Configuration) │ +│ ─ JSON/DB 설정으로 테넌트별 동작을 제어 │ +│ ─ Tenant.workflow_config에 저장 │ +│ ─ 변경: 설정값만 변경, 코드 변경 없음 │ +├─────────────────────────────────────────────────────┤ +│ Layer 3: 데이터 어댑터 (Data Adapters) │ +│ ─ 외부 데이터 소스별 변환 로직 │ +│ ─ speedfox_adapter, enkied_adapter, alpet_adapter │ +│ ─ 변경: 새 어댑터 추가 시에만 │ +└─────────────────────────────────────────────────────┘ +``` + +### 5.2 차이점별 구체적 해결 방안 + +#### ① 카운터 갱신 방식 — `Tenant.workflow_config.counter_policy` + +테넌트마다 카운터가 갱신되는 방식이 다르다. 이를 코드 분기가 아닌 **테넌트 설정**으로 해결한다. + +```json +// 스피폭스: PLC에서 shotno가 직접 오므로 자동 갱신 +{ + "counter_policy": { + "default_source": "auto_plc", + "plc_field_mapping": { + "count": "shotno", + "hours": null + } + } +} + +// 엔키드: PLC shots + 가동시간 둘 다 자동 갱신 +{ + "counter_policy": { + "default_source": "auto_plc", + "plc_field_mapping": { + "count": "shot_count", + "hours": "operating_hours" + } + } +} + +// 알펫: 가동시간은 자동, 화학약품 수명은 날짜 기반 (자동 계산) +{ + "counter_policy": { + "default_source": "auto_time", + "plc_field_mapping": { + "count": null, + "hours": "line_running_hours" + } + } +} +``` + +**카운터 서비스 로직** (코드 변경 없이 설정으로 동작): +```python +def update_counter(part: EquipmentPart, counter: PartCounter, source_data: dict, policy: dict): + if part.lifecycle_type == "count": + # PLC에서 카운트 필드 읽기 (필드명은 설정에서) + field = policy["plc_field_mapping"]["count"] + if field and field in source_data: + counter.current_value = source_data[field] + elif part.lifecycle_type == "hours": + field = policy["plc_field_mapping"]["hours"] + if field and field in source_data: + counter.current_value = source_data[field] + else: + # auto_time: 장착일로부터 경과 시간 자동 계산 + counter.current_value = hours_since(part.installed_at) + elif part.lifecycle_type == "date": + # 장착일로부터 경과 일수 자동 계산 + counter.current_value = days_since(part.installed_at) + + counter.lifecycle_pct = (counter.current_value / part.lifecycle_limit) * 100 +``` + +#### ② 품질 판정 로직 — 템플릿 spec_min/spec_max로 통일 + +품질 판정의 복잡도가 업체마다 다르지만, **모두 `spec_min`/`spec_max` 범위 체크로 환원**된다. + +| 업체 | 판정 방식 | spec_min/spec_max 표현 | +|------|----------|----------------------| +| 스피폭스 | OK/NG (이진) | boolean 항목으로 표현. spec 불필요 | +| 엔키드 | 6종 불량코드 + 신뢰도 | 각 측정항목마다 spec_min/max 설정. 불량코드는 select 항목으로 | +| 알펫 | 밀착력 N 값 | spec_min=5.0 (하한만). 두께: spec_min=0.95, spec_max=1.05 | + +**불량코드/신뢰도** 같은 엔키드 특유의 데이터는 어디에? +→ `InspectionTemplateItem`의 `select_options`로 불량코드를 선택지로 제공. +→ 신뢰도(confidence)는 numeric 항목으로 별도 추가. +→ **새로운 모델이 필요 없다.** + +#### ③ 알람 임계값 — 기존 3가지 + `trend_warning` 1가지 추가 (Oracle 권고) + +| 알람 유형 | 스피폭스 | 엔키드 | 알펫 | +|----------|---------|--------|------| +| `part_lifecycle` | 금형 9,000타 도달 | 금형 12,000타 도달, 슬리브 1,800시간 | 히터 4,500시간, 약품 27일 | +| `inspection_overdue` | 일일 프레스 검사 미실시 | 주간 금형 마모 측정 미실시 | 일일 농도 체크 미실시 | +| `spec_violation` | 비전 NG 발생 | X-ray 기포 검출, 치수 초과 | 밀착력 < 5.0N, 온도 범위 이탈 | +| `trend_warning` (**신규**) | — | 사출압력 연속 5회 경고구간 진입 | 라미네이팅 온도 연속 10회 편향 | + +→ **4가지 알람 유형으로 3개 업체 모두 커버 가능.** +→ `trend_warning`은 엔키드/알펫의 "경향 관리" 수요를 흡수. SPC 전체 구현 없이 실무적 알람 제공. +→ `InspectionTemplateItem`에 `warning_min`/`warning_max`/`trend_window` 3개 필드 추가로 구현. + +**trend_warning 동작 원리**: +- `spec_min`/`spec_max` = 경(hard) 이탈 → `spec_violation` (critical) +- `warning_min`/`warning_max` = 연(soft) 경고구간 +- `trend_window` = N → 최근 N회 연속 경고구간 진입 시 → `trend_warning` (warning) +- SPC(Cp, Cpk, 관리도)는 **대시보드 리포팅** 기능으로 별도 구현 (알람 트리거가 아님) + +#### ④ 검사 UX — 동일 컴포넌트, 다른 사용 패턴 + +| UX 관점 | 스피폭스 | 엔키드 | 알펫 | +|---------|---------|--------|------| +| **작업자 동선** | 10+대 순회 → 빠른 체크 | 1~2대 집중 → 정밀 측정 | 라인 모니터링 → 샘플 채취 | +| **검사 시간** | 짧음 (2~5분/대) | 김 (15~30분/대) | 중간 (모니터링 연속) | +| **동시 진행 세션** | 많음 (5~10개) | 적음 (1~2개) | 중간 (2~3개) | +| **모바일 필요** | 높음 (현장 순회) | 보통 (검사실) | 낮음 (제어실 데스크톱) | + +→ **`InspectionTemplate.inspection_mode` 필드 추가 (Oracle 권고)**: 3가지 프론트엔드 레이아웃으로 분리. +→ **`inspection_mode`는 테넌트가 아닌 템플릿에 설정** — 같은 테넌트도 검사 종류마다 다른 모드 사용 가능. + +| `inspection_mode` | 최적 대상 | UX 특징 | +|-------------------|----------|---------| +| `checklist` | 스피폭스 프레스 일일검사, 엔키드 시작 전 점검 | 큰 OK/NG 버튼, 스와이프 진행, 모바일 최적화 | +| `measurement` | 엔키드 품질검사, 스피폭스 비전 캘리브레이션 | 상세 폼, 이미지 업로드, 부품 추적성 | +| `monitoring` | 알펫 라인 모니터링 | 수치 테이블, 인라인 트렌드 스파크라인, 데스크톱 최적화 | + +**프론트엔드 구현**: +``` +dashboard/app/[tenant]/inspections/[id]/ + _layouts/ + ChecklistLayout.tsx # 빠른 체크리스트 (스와이프형) + MeasurementLayout.tsx # 정밀 측정 폼 (첨부파일 지원) + MonitoringLayout.tsx # 모니터링 판넬 (트렌드 차트) +``` +→ `next/dynamic`으로 3개 레이아웃 동적 로드. 같은 API, 같은 데이터 모델, 다른 렌더링. + +#### ⑤ 데이터 어댑터 — 업체별 독립 어댑터 + +| 어댑터 | 입력 | 출력 | 상태 | +|--------|------|------|------| +| `SpeedfoxAdapter` | PLC 27개 태그 (프레스) | TelemetryEvent | ✅ 구현됨 | +| `EnkiedAdapter` | 품질분석 결과 JSON (6종 불량코드) | InspectionEvent | ✅ 구현됨 | +| `AlpetAdapter` | PLC 41차원 센서 (연속공정) | TelemetryEvent | ❌ 신규 필요 | + +**AlpetAdapter 설계 방향**: +```python +class AlpetAdapter(BaseAdapter): + """연속공정(라미네이팅) PLC 데이터 → TelemetryEvent""" + + def __init__(self): + super().__init__(source_system="alpet") + self.tag_mapping = { + # 온도 (31개) + "PreHeat_Zone1": "preheat_zone1_temp", + "Lami_Top_Temp": "laminating_top_temp", + "Quench_Tank_Temp": "quench_tank_temp", + # 농도 (6개) + "NaOH_Conc": "naoh_concentration", + "CrO3_Conc": "cro3_concentration", + # ... (41차원 전체 매핑) + } + + def convert_telemetry(self, raw_data): + """PLC 연속공정 데이터 → TelemetryEvent""" + # SpeedfoxAdapter와 동일한 패턴 + ... + + def convert_inspection(self, raw_data): + raise NotImplementedError("Alpet uses manual inspection, not automated") +``` + +→ **기존 BaseAdapter 인터페이스 그대로 사용**. 새 어댑터 추가만 하면 됨. +→ 파이프라인 라우팅에 `elif source.lower() == "alpet":` 한 줄 추가. + +--- + +## 6. Tenant.workflow_config 전체 스키마 + +각 테넌트의 동작 차이를 흡수하는 **설정 JSON** 전체 구조: + +```json +{ + // --- 검사 모듈 설정 --- + "inspection": { + "flow": "template_based", // "template_based" (v2 기본) + "auto_save_interval_ms": 2000, // 자동저장 간격 (모든 테넌트 동일 권장) + "allow_concurrent_sessions": true, // 동시 진행 세션 허용 여부 + "max_concurrent_sessions": 10 // 최대 동시 세션 수 + }, + + // --- 카운터 정책 --- + "counter_policy": { + "default_source": "auto_plc", // "auto_plc" | "auto_time" | "manual" + "plc_field_mapping": { + "count": "shotno", // PLC 필드명 (null이면 해당 유형 미지원) + "hours": "operating_hours" // PLC 필드명 + }, + "auto_time_enabled": false // date/hours 유형의 자동 시간 계산 활성화 + }, + + // --- 알람 정책 --- + "alarm_policy": { + "overdue_check_method": "on_access", // "on_access" | "scheduler" + "default_alarm_threshold_pct": 90.0 // 부품 수명 알람 기본 임계값 (%) + }, + + // --- 품질검사 설정 --- + "quality": { + "subject_type_enabled": ["equipment"], // ["equipment"] | ["equipment", "product"] + "defect_codes": [], // 업체별 불량 코드 목록 (빈 배열이면 범용) + "attachment_types": ["image"] // ["image"] | ["image", "xray"] | [] + } +} +``` + +**업체별 설정 예시**: + +```json +// --- 스피폭스 --- +{ + "inspection": {"flow": "template_based", "max_concurrent_sessions": 20}, + "counter_policy": {"default_source": "auto_plc", "plc_field_mapping": {"count": "shotno", "hours": null}}, + "quality": {"subject_type_enabled": ["equipment"], "attachment_types": ["image"]} +} + +// --- 엔키드 --- +{ + "inspection": {"flow": "template_based", "max_concurrent_sessions": 5}, + "counter_policy": {"default_source": "auto_plc", "plc_field_mapping": {"count": "shot_count", "hours": "operating_hours"}}, + "quality": {"subject_type_enabled": ["equipment", "product"], "defect_codes": ["GAS_POROSITY","SHORT_SHOT","BURR_FLASH","SHRINKAGE","SURFACE_DEFECT","DIMENSION_WEIGHT"], "attachment_types": ["image", "xray"]} +} + +// --- 알펫 --- +{ + "inspection": {"flow": "template_based", "max_concurrent_sessions": 5}, + "counter_policy": {"default_source": "auto_time", "plc_field_mapping": {"count": null, "hours": "line_running_hours"}, "auto_time_enabled": true}, + "quality": {"subject_type_enabled": ["equipment"], "attachment_types": []} +} +``` + +--- + +## 7. 데이터 모델 영향도 분석 + +### 7.1 기존 설계 변경 불필요 (그대로 사용) + +| 모델 | 이유 | +|------|------| +| `Machine` | 3개 업체 모두 설비 단위 관리 | +| `EquipmentPart` | `lifecycle_type` = count/hours/date로 3개 업체 모두 커버 | +| `InspectionTemplate` | `subject_type` = equipment/product로 설비검사/품질검사 구분 | +| `InspectionTemplateItem` | `data_type` 4종으로 모든 검사항목 표현 가능 | +| `InspectionSession` | `template_snapshot` + `partial_results` JSON으로 유연하게 대응 | +| `PartCounter` | `current_value` + `lifecycle_pct`로 타발수/시간/날짜 모두 추적 | +| `PartReplacementLog` | 교체 이력 구조 동일 | +| `InspectionAlarm` | 3가지 알람 유형으로 3개 업체 모두 커버 | + +### 7.2 소규모 확장 필요 (Oracle 컨설팅 반영) + +| 변경 | 내용 | 영향도 | +|------|------|--------| +| `Tenant.workflow_config` | 위 섹션 6의 JSON 스키마 적용 | 기존 필드 활용 (스키마만 확장) | +| `EquipmentPart.counter_source` | 카운터 갱신 방식 명시 (`auto_plc`/`auto_time`/`manual`) | 컬럼 1개 추가 | +| `InspectionTemplate.inspection_mode` | 검사 UX 모드 (`checklist`/`measurement`/`monitoring`) | 컬럼 1개 추가 | +| `InspectionTemplateItem.warning_min/max` | 트렌드 경고 소프트 한계값 | 컬럼 2개 추가 | +| `InspectionTemplateItem.trend_window` | 연속 N회 경고구간 진입 시 trend_warning 알람 | 컬럼 1개 추가 | +| `InspectionSession.lot_number` | 엔키드 IATF 추적성용 로트/시리얼 번호 (optional) | 컬럼 1개 추가 | +| `InspectionAlarm.alarm_type` | `trend_warning` 추가 (기존 3종 → 4종) | enum 값 1개 추가 | +| `AlpetAdapter` | 신규 어댑터 클래스 | 파일 1개 추가 | +| 파이프라인 라우팅 | `elif source == "alpet":` | 라인 2~3개 추가 | + +### 7.3 변경 불필요 확인 — 신규 테이블 없음 + +"업체마다 다른 검사 로직" 때문에 **새로운 테이블이 필요한가?** → **아니오.** + +- 검사항목 차이 → `InspectionTemplateItem`의 설정값 차이로 해결 +- 불량코드 차이 → `select_options` JSON으로 해결 +- 카운터 소스 차이 → `workflow_config` 설정으로 해결 +- 알람 임계값 차이 → `EquipmentPart.alarm_threshold` 개별 설정으로 해결 + +--- + +## 8. 구현 우선순위 (3개 업체 동시 지원 관점) + +### Phase 1 — 공통 엔진 우선 (변경 없음) +기존 development-plan.md의 Phase 0~7 그대로 진행. +**스피폭스를 1차 테스트 테넌트로 사용** (설비 수 가장 많고, 요구사항 가장 구체적). + +### Phase 1+ — 엔키드/알펫 테넌트 설정 추가 +1. `Tenant.workflow_config` 스키마에 `counter_policy`, `quality` 설정 추가 +2. `EquipmentPart`에 `counter_source` 컬럼 추가 +3. 엔키드/알펫 테넌트 생성 + 설정값 적용 +4. 엔키드/알펫 설비 + 부품 + 템플릿 시드 데이터 준비 + +### Phase 2+ — 알펫 어댑터 개발 +1. `AlpetAdapter` 구현 (BaseAdapter 상속) +2. 파이프라인 라우팅 확장 +3. 연속공정 특화 템플릿 설계 (온도 프로파일 구간별 체크) + +### Phase 3+ — 업체별 UX 최적화 (필요시) +- 스피폭스: 대량 설비 빠른 순회 뷰 (카드 그리드) +- 엔키드: 정밀 검사 상세 뷰 (X-ray 이미지 뷰어) +- 알펫: 연속 모니터링 대시보드 뷰 (라인 상태 타임라인) + +--- + +## 9. 리스크 및 미결 사항 + +### 9.1 리스크 + +| 리스크 | 영향 | 완화 방안 | +|--------|------|----------| +| 알펫의 연속공정 데이터가 이산공정 모델에 안 맞을 수 있음 | 중 | 코일 번호를 product_code로 사용, 구간을 세션으로 매핑 | +| 3개 업체 설비 수 차이 (304 vs 7 vs 15) → 성능 | 중 | 인덱스 최적화, 페이지네이션 (이미 설계됨) | +| 업체별 현장 자료 미확보 | 고 | 설비 목록, PLC 태그, 검사지 등 사전 수집 필수 | +| 엔키드 IATF 16949 규격 요구 | 중 | 검사 이력 추적성 확보 (template_snapshot으로 이미 대응) | + +### 9.2 미결 사항 + +1. **알펫 PLC 태그 리스트** 미확보 — AlpetAdapter 구현 시 필요 +2. **엔키드 X-ray 이미지 저장소** — 첨부파일 스토리지 설계 필요 (S3? 로컬?) +3. **연속공정 "검사 세션" 단위** — 코일 1롤 = 1세션? 시간 구간 = 1세션? +4. **SPC 통계적 공정관리** — 3개 업체 모두 향후 필요하지만 v2 범위 외 +5. **AI 연동 포인트** — v2에서는 수동 검사만. AI 자동 판정은 다음 버전 + +--- + +## 10. 결론 + +> **핵심 원칙 (Oracle 확인): "TYPE 필드로 분기하고, 테넌트 ID로는 절대 분기하지 않는다."** +> 모든 차이는 `lifecycle_type`, `data_type`, `inspection_mode`, `alarm_type` 등 **엔티티의 TYPE 필드**에서 결정됨. +> 코어 앱에 `if tenant == "spifox"` 같은 분기는 **0개**. + +- **데이터 모델**: 기존 10개 테이블 설계 유지 + 소규모 컬럼 추가 (6개 컬럼) +- **알람 유형 확장**: 기존 3종 → 4종 (`trend_warning` 추가) +- **검사 UX 모드**: `InspectionTemplate.inspection_mode` = `checklist`/`measurement`/`monitoring` +- **추적성**: `InspectionSession.lot_number` (엔키드 IATF용, optional) +- **신규 코드**: `AlpetAdapter` 1개 파일 + 프론트엔드 레이아웃 3개 +- **테넌트 설정**: `workflow_config` JSON으로 카운터 정책, 품질 설정, 알람 정책 제어 +- **검사 항목/스펙**: `InspectionTemplate` + `InspectionTemplateItem`으로 업체별 자유 설정 + +### 주의사항 (Oracle 경고) + +1. **알펫 설비 계층**: 알펫의 `equipment_id`는 개별 기계가 아닌 **라인 구간** (전처리존, 라미네이팅 스테이션 등). Machine 모델이 "라인 > 구간" 계층을 지원하는지 확인 필요 +2. **엔키드 로트 추적**: IATF 16949은 검사 결과 → 특정 로트/시리얼 연결 필수. `InspectionSession.lot_number` 필드로 대응 +3. **스피폭스 대규모 알람**: 304대 × 60개 PLC 파라미터 매 수신 시 알람 평가 → 과부하 위험. **알람은 검사 제출/카운터 갱신 시점에만 평가** (PLC 원시 데이터 수신 시에는 평가 안 함) diff --git a/planning/progress.md b/planning/progress.md new file mode 100644 index 0000000..90f7188 --- /dev/null +++ b/planning/progress.md @@ -0,0 +1,4 @@ +# Progress + +## 2026-02-10 +- Phase 0 완료: 프로젝트 부트스트래핑 diff --git a/planning/spifox-inspection-analysis.md b/planning/spifox-inspection-analysis.md new file mode 100644 index 0000000..1e13da4 --- /dev/null +++ b/planning/spifox-inspection-analysis.md @@ -0,0 +1,337 @@ +# 스피폭스 설비검사/품질검사 종합 분석 및 개발계획 + +> 작성일: 2026-02-09 +> 참고자료: 개발회의.md (멘토 회의), 스피폭스 사업계획서 v2, factoryOps 코드베이스, Oracle 아키텍처 컨설팅 + +--- + +## 1. 스피폭스 최종 목표 + +### 1.1 회사 개요 +| 항목 | 내용 | +|------|------| +| 기업명 | (주)스피폭스 (Speefox CO., LTD.) | +| 설립 | 1985년 (약 40년 업력) | +| 주력 제품 | 알루미늄 전해 콘덴서용 케이스 | +| 시장 점유율 | SMD 전해 콘덴서 LIQUID TYPE **세계 시장 50% 이상** | +| 주요 거래선 | 케미콘, 니치콘, 파나소닉 등 글로벌 기업 | +| 특이사항 | 스크랩 재활용 '파파야시스템'으로 자원순환율 100% 달성 | + +### 1.2 최종 비전 +**"시뮬레이션 기반 최적 공급망 관리(SCM) 및 물류 자동화 → 자율형 공장"** + +구체적으로: +1. **디지털 트윈 기반 생산계획 최적화** — What-if 시뮬레이션으로 150종 반제품/500종 완제품의 우선순위 및 설비 할당 +2. **AI 금형 마모 예측** — LSTM 시계열 예측, 정확도 95% 이상 → 고장 전 부품 교체 +3. **AI 비전 품질 전수검사** — 머신비전 30대→80대 확장, CNN 결함 감지, 불량률 1.51%→1.00% +4. **자율형 공장** — 현 2단계(관제) → 3단계(모델링/시뮬레이션 디지털 트윈) + +### 1.3 정량 KPI 목표 +| KPI | 현재 | 목표 | 개선율 | +|-----|------|------|--------| +| 시간당 생산량 | 1,560 kpcs/h | 1,700 kpcs/h | +9% | +| 재공재고량 | 79,983 kpcs | 72,000 kpcs | -10% | +| 완제품 불량률 | 1.51% | 1.00% | -34% | +| 생산계획 시뮬레이션 정확도 | 0% | 85% | 신규 | +| 디지털트윈 리프레시 | 0 fps | 20 fps | 신규 | +| AI 품질 이상감지 정밀도 | 0% | 95% | 신규 | + +### 1.4 주요 설비 현황 +| 설비 유형 | 대수 | 비고 | +|----------|------|------| +| 완제품 프레스 | 186대 | 핵심 생산 설비 | +| 반제품 프레스 | 30대 | | +| 머신비전 검사장치 | 30대 (→80대) | 현재 전체의 16%, 확대 예정 | +| 건조설비 | 8대 | | +| 탈유기 | 6대 | | +| 세척기 | 8대 | | +| 열처리기 | 4대 | | +| 자동창고 | WMS 연동 | | + +### 1.5 현재 문제점 (사업계획서 기준) +1. **생산계획**: 단순 모니터링 수준, 시뮬레이션 없음, 긴급 주문 대응 부족 +2. **품질관리**: 자동검사 범위 16%, 숙련공 경험 의존, 품질-공정 데이터 연계 미흡 +3. **설비관리**: 금형 마모 사전 예측 없음 → 갑작스러운 품질 문제 및 라인 정지 + +--- + +## 2. 개발 회의 핵심 분석 + +### 2.1 선배 개발자의 아키텍처 지침 + +> **핵심 원리: 설비검사와 품질검사는 동일한 원리, 대상만 다르다** +> - 설비검사 → 대상은 **설비(Equipment)** +> - 품질검사 → 대상은 **생산품(Product)** + +#### Layer 1 — 기준정보 (Base Information) +``` +설비/생산품 기본정보 + └── 관리 영역 (Management Area) + ├── 검사 주기 설정: 1일 / 주간 / 월간 / 연간 / 수시 + ├── 검사 항목 (사용자 자유 입력) + │ ├── 항목명, 카테고리 + │ ├── 값 유형: 수치 / 날짜 / 텍스트 / 선택 + │ └── 스펙 범위 (상한/하한) + └── 부품 연결 (Parts Linkage) + ├── 수명 관리 방식: 시간(hours) / 타발수(count) / 날짜(date) + ├── 수명 한계값 + └── 알람 조건 (e.g. 9,000개 도달 시 알람) +``` + +#### Layer 2 — 운영 데이터 (Operational Data) +- **누적 카운터**: 설비별 부품별 누적값 관리, 부품 교체 시 리셋 +- **데이터 소스**: + - PLC 인터페이스 (직접): 타발수, 온도, 압력 등 + - 생산 실적 (간접): 작업지시 → 실적 등록 → 카운터 증가 +- **핵심**: 설비 자체 데이터만 쓸지, 생산 데이터와 연결할지 **구분 필요** + +#### Layer 3 — 액션/이력 (Actions & History) +- 임계값 도달 시 **알람** +- 부품 교체 **이력 추적** (언제, 누가, 누적값 얼마에서) +- 검사 결과 **이력 조회** (설비별, 부품별, 기간별) + +#### 핵심 설계 원칙 +1. 기준정보가 먼저 → 그 다음에 목표에 따라 테이블이 생성됨 +2. **진행 중인 검사는 반드시 저장** (화면 이탈 시 초기화 금지) +3. 목표는 두 종류: + - **데이터 목표**: "부품 고장 전 교체" 같은 실질적 목표 + - **UX 목표**: "터치 두 번 만에 완료", "화면 들락날락 금지" 같은 사용성 목표 +4. UX 목표를 못 지키면 **시스템 사용률 90% 실패** (실사용 요구가 핵심) + +### 2.2 선배가 요청한 사전 준비 리스트 +- [ ] 스피폭스 설비 목록 전체 뽑기 +- [ ] 설비별 인터페이스/수집 데이터 종류 정리 +- [ ] 부품 종류 및 검사 항목 리스트 확보 +- [ ] 스피폭스의 **최종 목표 리스트** 뽑기 → (이건 사업계획서에서 추출 완료) +- [ ] 위 자료로 기준정보 테이블 설계 +- [ ] 화면 구성은 편의성 기준으로 설계 + +--- + +## 3. 현재 코드베이스 Gap 분석 + +### 3.1 이미 구현된 것 (FactoryOps) +| 기능 | 상태 | 위치 | +|------|------|------| +| 설비(Machine) CRUD | ✅ 완료 | `models.py`, `equipment_sync.py` | +| 검사 계획(InspectionPlan) | ✅ 완료 | `api/inspection.py` | +| 검사 기록(Inspection) | ✅ 완료 | `api/inspection.py` | +| 체크리스트 (JSON 기반) | ✅ 완료 | `InspectionPlan.checklist`, `Inspection.checklist_results` | +| 검사 분석 (OK/NG 비율) | ✅ 완료 | `inspection_analytics` | +| PLC 데이터 수신/저장 | ✅ 완료 | `DiecastingMachineData`, 60+ 파라미터 | +| 건강점수 / 스코어링 | ✅ 완료 | `scoring/health_calculator.py` | +| 알람 서비스 | ✅ 완료 | `alarm/service.py` | +| 불량 유형 6가지 | ✅ 완료 | `domain/defect_type.py` | +| 고장 유형 13가지 | ✅ 완료 | `domain/failure_type.py` | +| 부품(Part) 재고 관리 | ✅ 완료 | `parts`, `inventory_items` | +| PM 예방보전 스케줄 | ✅ 완료 | `pm_schedules`, `pm_work_orders` | +| 온톨로지 Knowledge Graph | ✅ 완료 | `ontology_edges` | +| 프론트엔드 검사 페이지 | ✅ 완료 | `/inspections`, `/inspection-plans`, `/analytics` | + +### 3.2 아직 없는 것 (Gap) +| 기능 | 중요도 | 설명 | +|------|--------|------| +| **검사 기준정보 템플릿** | 🔴 최우선 | 설비/생산품별 검사항목 사전 정의 시스템 | +| **설비 부품 관리 (장착 부품)** | 🔴 최우선 | 재고 부품이 아닌, 설비에 **장착된** 부품 인스턴스 | +| **부품 수명 관리** | 🔴 최우선 | 시간/타발수/날짜 기반 수명 추적 | +| **누적 카운터 시스템** | 🔴 최우선 | 부품별 누적값 관리, 교체 시 리셋 | +| **부품 교체 이력** | 🟡 중요 | 교체 시점 스냅샷, 이력 추적 | +| **검사 진행 중 저장** | 🟡 중요 | InspectionSession, partial_results 저장 | +| **품질검사 전용 기능** | 🟡 중요 | 생산품 대상, 로트/샘플 사이즈, AQL | +| **반복 검사 스케줄링** | 🟡 중요 | 일간/주간/월간 자동 생성 | +| **검사 알람** | 🟡 중요 | 부품 수명 초과, 검사 미실시, 스펙 이탈 | +| **품질검사 대시보드** | 🟠 보통 | 설비검사와 분리된 품질 분석 화면 | +| **SPC 통계적 공정관리** | 🟠 보통 | 관리도, 파레토, 상관 분석 | + +--- + +## 4. 아키텍처 설계 방향 + +### 4.1 핵심 원칙: 단일 엔진, 이중 대상 + +> Oracle 컨설팅 결과: **설비검사와 품질검사는 하나의 엔진으로 구현하되, `subject_type` 구분자로 분리** + +``` +InspectionTemplate + ├── subject_type = 'equipment' → machine_id에 바인딩 + └── subject_type = 'product' → product_type_id에 바인딩 +``` + +**이유:** +- 90%의 로직이 동일 (템플릿 → 세션 → 결과 → 이력) +- 하나의 코드베이스로 유지보수 +- 분석 시 교차 쿼리 용이 +- 선배 개발자가 명확히 "같은 원리"라고 언급 + +### 4.2 신규 테이블 설계 + +``` +[Layer 1: 기준정보] + EquipmentPart — 설비에 장착된 부품 인스턴스 + InspectionTemplate — 검사 기준정보 템플릿 (설비/품질 공용) + InspectionTemplateItem — 검사 항목 정의 (data_type, spec_min/max, 부품연결, PLC태그) + +[Layer 2: 누적 카운터] + PartCounter — 부품별 누적값 (hours/count/date), 교체 시 리셋 + PartReplacementLog — 부품 교체 이력 + 교체 시점 스냅샷 + +[Layer 3: 검사 실행] + InspectionSession — 진행 중 저장 지원, partial_results JSON + +[Layer 4: 알람] + InspectionAlarm — 부품수명/검사미실시/스펙이탈 알람 +``` + +### 4.3 기존 모델과의 관계 + +``` +Machine (기존) ←──── EquipmentPart (신규) + │ │ + ├── InspectionPlan ←── InspectionTemplate (신규, 기존 plan 확장) + │ │ │ + │ └── Inspection ←── InspectionSession (신규, 진행중 저장) + │ + └── DiecastingMachineData ──→ PartCounter (신규, PLC값으로 카운터 갱신) + +Part (기존, 재고) ←── EquipmentPart.part_id (장착 부품 ↔ 재고 부품 연결) +``` + +### 4.4 데이터 흐름 + +``` +[데이터 소스] [카운터 갱신] [액션] +PLC → DiecastingMachineData ──→ PartCounter 갱신 ──→ 알람 발생 + (shotno 증가분) (lifecycle_pct > 알람%) + │ +작업지시 → 생산실적 ────────────→ PartCounter 갱신 + (ok_qty + ng_qty) + │ +부품 교체 ────────────────────→ PartCounter 리셋 + + PartReplacementLog 기록 +``` + +--- + +## 5. 개발계획 (Phase별) + +### 전체 로드맵 (FactoryOps 범위 기준) + +| Phase | 기간 | 내용 | 스피폭스 KPI 연관 | +|-------|------|------|------------------| +| **P1** | 1~3개월 | 기준정보 관리 + 설비검사 세션 (진행중 저장) | 기반 | +| **P2** | 3~5개월 | 부품 수명 카운터 + PLC 연동 + 알람 | 금형 마모 추적 기반 | +| **P3** | 5~7개월 | 품질검사 (같은 엔진, product subject) + 생산실적 연동 | 불량률 1.00% 추적 | +| **P4** | 7~10개월 | 반복 스케줄 엔진 + 대시보드 분석 고도화 | OEE 가시성 | +| **P5** | 10~14개월 | AI 연동 포인트: 카운터→LSTM 금형예측, 검사이미지→CNN | AI 95% 정확도 | +| **P6** | 14~18개월 | 디지털트윈 데이터 피드 + 이력 추적 고도화 | 시뮬레이션 | +| **P7** | 18~24개월 | 부품 자동 발주 + 물류 최적화 | 자율화 | + +### P1 상세 (기준정보 + 설비검사 세션) — 최우선 + +#### 백엔드 +1. `EquipmentPart` 모델 + CRUD API + - 설비별 장착 부품 등록/수정/삭제 + - 재고 Part와 연결 (optional) + - 수명 관리 설정 (lifecycle_type, lifecycle_limit) + +2. `InspectionTemplate` + `InspectionTemplateItem` 모델 + CRUD API + - 설비검사/품질검사 공용 템플릿 + - 검사 주기(scope) 설정 + - 항목별: data_type, spec_min/max, 부품연결, 데이터소스 + +3. `InspectionSession` 모델 + API + - 진행 중 저장 (`partial_results` JSON) + - 자동저장 엔드포인트 (debounced) + - 세션 완료 → 최종 결과 생성 + +#### 프론트엔드 +4. 기준정보 관리 화면 + - 설비 선택 → 부품 관리 탭 + - 검사 템플릿 편집기 (항목 추가/삭제/순서변경) + - 주기 설정 UI + +5. 검사 실행 화면 (모바일 대응) + - 템플릿 기반 체크리스트 렌더링 + - 자동저장 (2초 debounce) + - 화면 이탈 시 경고 + 자동 저장 + +### P2 상세 (카운터 + 알람) — 핵심 가치 + +#### 백엔드 +1. `PartCounter` 모델 + 갱신 서비스 + - PLC 데이터 수신 시 자동 갱신 (기존 diecasting ingestion 확장) + - 생산실적 입력 시 카운터 증가 + - lifecycle_pct 자동 계산 + +2. `PartReplacementLog` 모델 + 교체 API + - 교체 시 카운터 리셋 + - 교체 시점 스냅샷 저장 + +3. `InspectionAlarm` 모델 + 알람 서비스 + - 부품 수명 초과 알람 (`part_lifecycle`) + - 정기검사 미실시 알람 (`inspection_overdue`) + - 스펙 이탈 알람 (`spec_violation`) + +#### 프론트엔드 +4. 부품 수명 현황 대시보드 + - 설비별 부품 수명 게이지 (%) + - 알람 목록 + 확인(acknowledge) 기능 + - 부품 교체 이력 타임라인 + +--- + +## 6. 스피폭스에서 받아야 할 자료 + +선배 개발자가 지시한 사전 수집 자료: + +| # | 자료 | 용도 | 상태 | +|---|------|------|------| +| 1 | **설비 전체 목록** | 기준정보 등록 | ❌ 미확보 | +| 2 | **설비별 수집 데이터 종류** (PLC 태그 리스트) | 인터페이스 설계, 자동 입력 항목 | ❌ 미확보 | +| 3 | **부품 종류 및 규격** | 부품 수명 관리 설정 | ❌ 미확보 | +| 4 | **기존 검사 항목 리스트** (현장 검사지) | 템플릿 초기 데이터 | ❌ 미확보 | +| 5 | **관리 목표 리스트** | 테이블/알람 설계 기준 | ✅ 사업계획서에서 추출 | +| 6 | **현장 작업자 UX 요구** | 화면 구성 기준 | ❌ 미확보 | + +> **[액션 아이템]** 1~4번, 6번 자료를 스피폭스에 요청해야 P1 설계를 확정할 수 있음 + +--- + +## 7. 역할 분담 명확화 + +| 담당 | 범위 | +|------|------| +| **FactoryOps** | 설비 데이터 수집/분석, 스코어링, 알람, 예지보전, **설비검사**, **품질검사 기준정보** | +| **digital-twin-web** | 디지털트윈 3D 시각화, AI 노트북, ESG, AAS 표준 | +| **digital-twin-gpu** | AI 모델 (LSTM 금형예측, CNN 결함감지) | +| **MES (별도)** | 영업, 구매, 생산실적, 금형관리, 가동률, 작업지시 | + +### FactoryOps ↔ MES 연동 포인트 +- MES의 **생산실적** → FactoryOps **PartCounter** 갱신 (API 연동) +- MES의 **작업지시** → FactoryOps 검사 트리거 (품질검사 시점) +- FactoryOps의 **알람** → MES 정비 요청 (웹훅) + +--- + +## 8. 주의사항 (Oracle 컨설팅 기반) + +### 8.1 카운터 레이스 컨디션 +PLC 갱신과 수동 리셋이 동시에 발생할 수 있음. `SELECT FOR UPDATE` 또는 optimistic locking 필수. + +### 8.2 템플릿 버전 관리 +검사 수행 후 템플릿이 변경되어도 **기존 이력은 변경 불가**. 세션 시작 시 템플릿 스냅샷을 `InspectionSession`에 저장. + +### 8.3 자동저장 UX +선배 개발자가 강조한 "진행 중 저장" — 필드 변경마다 2초 debounce 자동저장. SWR 낙관적 업데이트 패턴 적용. + +### 8.4 "테이블은 목표에 따라 생성된다" +동적 DDL이 아님. **InspectionTemplate + InspectionTemplateItem**의 JSON 기반 유연한 구조로 해결. 고객별로 다른 검사항목을 같은 테이블 구조에서 수용. + +--- + +## 9. 다음 단계 (즉시 실행) + +1. **스피폭스에 자료 요청** (설비목록, PLC태그, 부품목록, 검사지, UX요구) +2. 자료 수집 후 **기준정보 테이블 확정** 및 상세 ERD 작성 +3. **P1 백엔드 개발 착수** (EquipmentPart, InspectionTemplate, InspectionSession) +4. **P1 프론트엔드 설계** (화면 와이어프레임, 모바일 대응) diff --git a/planning/task_plan.md b/planning/task_plan.md new file mode 100644 index 0000000..bf0bf9a --- /dev/null +++ b/planning/task_plan.md @@ -0,0 +1,11 @@ +# Task Plan + +## Current Phase: Phase 0 → Phase 1 + +- [x] Phase 0-1: 기존 프로젝트 백업 +- [x] Phase 0-2: 새 프로젝트 생성 + git init +- [x] Phase 0-3: requirements.txt +- [x] Phase 0-4: Next.js 초기화 + swr, clsx +- [x] Phase 0-5~0-9: 설정 파일들 +- [ ] Phase 1 Backend: DB config, models, auth, tenant, main.py, alembic, tests +- [ ] Phase 1 Frontend: api.ts, contexts, layout, globals.css, login, components diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..2f4c80e --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +asyncio_mode = auto diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..82aea87 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,18 @@ +# Core +fastapi==0.115.0 +uvicorn[standard]==0.30.0 +python-multipart==0.0.9 + +# Database (PostgreSQL 전용, async) +sqlalchemy[asyncio]==2.0.36 +asyncpg==0.30.0 +alembic==1.14.0 + +# Auth +PyJWT==2.9.0 +bcrypt==4.2.0 + +# Testing +pytest==8.2.2 +pytest-asyncio==0.24.0 +httpx==0.27.0 diff --git a/scripts/seed.py b/scripts/seed.py new file mode 100644 index 0000000..f8b3ada --- /dev/null +++ b/scripts/seed.py @@ -0,0 +1,115 @@ +import asyncio +import os +import sys +import uuid + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from dotenv import load_dotenv + +load_dotenv() + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker + +from src.database.models import Base, User, Tenant +from src.auth.password import hash_password + +DATABASE_URL = os.getenv( + "DATABASE_URL", + "postgresql+asyncpg://factoryops:factoryops@localhost:5432/factoryops_v2", +) + +TENANTS = [ + { + "id": "spifox", + "name": "SpiFox", + "industry_type": "semiconductor", + }, + { + "id": "enkid", + "name": "Enkid", + "industry_type": "manufacturing", + }, + { + "id": "alpet", + "name": "Alpet", + "industry_type": "chemical", + }, +] + +SUPERADMIN = { + "email": "admin@factoryops.com", + "password": "admin1234", + "name": "Super Admin", + "role": "superadmin", + "tenant_id": None, +} + +TENANT_ADMINS = [ + { + "email": "admin@spifox.com", + "password": "spifox1234", + "name": "SpiFox Admin", + "role": "tenant_admin", + "tenant_id": "spifox", + }, + { + "email": "admin@enkid.com", + "password": "enkid1234", + "name": "Enkid Admin", + "role": "tenant_admin", + "tenant_id": "enkid", + }, + { + "email": "admin@alpet.com", + "password": "alpet1234", + "name": "Alpet Admin", + "role": "tenant_admin", + "tenant_id": "alpet", + }, +] + + +async def seed(): + engine = create_async_engine(DATABASE_URL, echo=False) + session_factory = async_sessionmaker( + engine, class_=AsyncSession, expire_on_commit=False + ) + + async with session_factory() as db: + for t in TENANTS: + existing = await db.execute(select(Tenant).where(Tenant.id == t["id"])) + if existing.scalar_one_or_none(): + print(f" Tenant '{t['id']}' already exists, skipping") + continue + db.add(Tenant(**t)) + print(f" + Tenant '{t['id']}' ({t['name']})") + await db.commit() + + all_users = [SUPERADMIN] + TENANT_ADMINS + for u in all_users: + existing = await db.execute(select(User).where(User.email == u["email"])) + if existing.scalar_one_or_none(): + print(f" User '{u['email']}' already exists, skipping") + continue + db.add( + User( + id=uuid.uuid4(), + email=u["email"], + password_hash=hash_password(u["password"]), + name=u["name"], + role=u["role"], + tenant_id=u["tenant_id"], + ) + ) + print(f" + User '{u['email']}' (role={u['role']})") + await db.commit() + + await engine.dispose() + print("\nSeed complete.") + + +if __name__ == "__main__": + print("Seeding FactoryOps v2 database...\n") + asyncio.run(seed()) diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/api/__init__.py b/src/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/api/equipment_parts.py b/src/api/equipment_parts.py new file mode 100644 index 0000000..6430aa1 --- /dev/null +++ b/src/api/equipment_parts.py @@ -0,0 +1,299 @@ +from datetime import datetime, timezone +from typing import Optional +from uuid import UUID + +from fastapi import APIRouter, HTTPException, Depends, Path +from pydantic import BaseModel +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from src.database.config import get_db +from src.database.models import Machine, EquipmentPart, PartCounter +from src.auth.models import TokenData +from src.auth.dependencies import require_auth, verify_tenant_access + +router = APIRouter(tags=["equipment_parts"]) + + +class PartCreate(BaseModel): + name: str + part_number: Optional[str] = None + category: Optional[str] = None + lifecycle_type: str # hours | count | date + lifecycle_limit: float + alarm_threshold: float = 90.0 + counter_source: str = "manual" # auto_plc | auto_time | manual + installed_at: Optional[str] = None + + +class PartUpdate(BaseModel): + name: Optional[str] = None + part_number: Optional[str] = None + category: Optional[str] = None + lifecycle_type: Optional[str] = None + lifecycle_limit: Optional[float] = None + alarm_threshold: Optional[float] = None + counter_source: Optional[str] = None + + +def _format_ts(val) -> Optional[str]: + if val is None: + return None + return val.isoformat() if hasattr(val, "isoformat") else str(val) + + +def _part_to_dict(p: EquipmentPart, counter: Optional[PartCounter] = None) -> dict: + result = { + "id": str(p.id), + "tenant_id": str(p.tenant_id), + "machine_id": str(p.machine_id), + "name": str(p.name), + "part_number": str(p.part_number) if p.part_number else None, + "category": str(p.category) if p.category else None, + "lifecycle_type": str(p.lifecycle_type), + "lifecycle_limit": float(p.lifecycle_limit), + "alarm_threshold": float(p.alarm_threshold or 90.0), + "counter_source": str(p.counter_source or "manual"), + "installed_at": _format_ts(p.installed_at), + "is_active": bool(p.is_active), + "created_at": _format_ts(p.created_at), + "updated_at": _format_ts(p.updated_at), + } + if counter: + result["counter"] = { + "current_value": float(counter.current_value or 0), + "lifecycle_pct": float(counter.lifecycle_pct or 0), + "last_reset_at": _format_ts(counter.last_reset_at), + "last_updated_at": _format_ts(counter.last_updated_at), + "version": int(counter.version or 0), + } + return result + + +async def _verify_machine( + db: AsyncSession, tenant_id: str, machine_id: UUID +) -> Machine: + stmt = select(Machine).where( + Machine.id == machine_id, Machine.tenant_id == tenant_id + ) + result = await db.execute(stmt) + machine = result.scalar_one_or_none() + if not machine: + raise HTTPException(status_code=404, detail="설비를 찾을 수 없습니다.") + return machine + + +@router.get("/api/{tenant_id}/machines/{machine_id}/parts") +async def list_parts( + tenant_id: str = Path(...), + machine_id: UUID = Path(...), + current_user: TokenData = Depends(require_auth), + db: AsyncSession = Depends(get_db), +): + verify_tenant_access(tenant_id, current_user) + await _verify_machine(db, tenant_id, machine_id) + + stmt = ( + select(EquipmentPart) + .options(selectinload(EquipmentPart.counter)) + .where( + EquipmentPart.machine_id == machine_id, + EquipmentPart.tenant_id == tenant_id, + EquipmentPart.is_active == True, + ) + .order_by(EquipmentPart.name) + ) + result = await db.execute(stmt) + parts = result.scalars().all() + + return {"parts": [_part_to_dict(p, p.counter) for p in parts]} + + +@router.post("/api/{tenant_id}/machines/{machine_id}/parts") +async def create_part( + body: PartCreate, + tenant_id: str = Path(...), + machine_id: UUID = Path(...), + current_user: TokenData = Depends(require_auth), + db: AsyncSession = Depends(get_db), +): + verify_tenant_access(tenant_id, current_user) + await _verify_machine(db, tenant_id, machine_id) + + if body.lifecycle_type not in ("hours", "count", "date"): + raise HTTPException( + status_code=400, + detail="lifecycle_type은 hours, count, date 중 하나여야 합니다.", + ) + + if body.counter_source not in ("auto_plc", "auto_time", "manual"): + raise HTTPException( + status_code=400, + detail="counter_source는 auto_plc, auto_time, manual 중 하나여야 합니다.", + ) + + dupe_stmt = select(EquipmentPart).where( + EquipmentPart.tenant_id == tenant_id, + EquipmentPart.machine_id == machine_id, + EquipmentPart.name == body.name, + EquipmentPart.is_active == True, + ) + if (await db.execute(dupe_stmt)).scalar_one_or_none(): + raise HTTPException( + status_code=409, detail=f"같은 이름의 부품 '{body.name}'이 이미 존재합니다." + ) + + installed_dt = None + if body.installed_at: + try: + installed_dt = datetime.fromisoformat( + body.installed_at.replace("Z", "+00:00") + ) + except ValueError: + raise HTTPException( + status_code=400, + detail="installed_at 형식이 올바르지 않습니다. ISO 8601 형식을 사용하세요.", + ) + + part = EquipmentPart( + tenant_id=tenant_id, + machine_id=machine_id, + name=body.name, + part_number=body.part_number, + category=body.category, + lifecycle_type=body.lifecycle_type, + lifecycle_limit=body.lifecycle_limit, + alarm_threshold=body.alarm_threshold, + counter_source=body.counter_source, + installed_at=installed_dt, + ) + db.add(part) + await db.flush() + + now = datetime.now(timezone.utc) + counter = PartCounter( + tenant_id=tenant_id, + equipment_part_id=part.id, + current_value=0, + lifecycle_pct=0, + last_reset_at=installed_dt or now, + last_updated_at=now, + ) + db.add(counter) + + await db.commit() + await db.refresh(part) + await db.refresh(counter) + + return _part_to_dict(part, counter) + + +@router.get("/api/{tenant_id}/parts/{part_id}") +async def get_part( + tenant_id: str = Path(...), + part_id: UUID = Path(...), + current_user: TokenData = Depends(require_auth), + db: AsyncSession = Depends(get_db), +): + verify_tenant_access(tenant_id, current_user) + + stmt = ( + select(EquipmentPart) + .options(selectinload(EquipmentPart.counter)) + .where(EquipmentPart.id == part_id, EquipmentPart.tenant_id == tenant_id) + ) + result = await db.execute(stmt) + part = result.scalar_one_or_none() + + if not part: + raise HTTPException(status_code=404, detail="부품을 찾을 수 없습니다.") + + return _part_to_dict(part, part.counter) + + +@router.put("/api/{tenant_id}/parts/{part_id}") +async def update_part( + body: PartUpdate, + tenant_id: str = Path(...), + part_id: UUID = Path(...), + current_user: TokenData = Depends(require_auth), + db: AsyncSession = Depends(get_db), +): + verify_tenant_access(tenant_id, current_user) + + stmt = select(EquipmentPart).where( + EquipmentPart.id == part_id, EquipmentPart.tenant_id == tenant_id + ) + result = await db.execute(stmt) + part = result.scalar_one_or_none() + + if not part: + raise HTTPException(status_code=404, detail="부품을 찾을 수 없습니다.") + + if body.name is not None: + dupe_stmt = select(EquipmentPart).where( + EquipmentPart.tenant_id == tenant_id, + EquipmentPart.machine_id == part.machine_id, + EquipmentPart.name == body.name, + EquipmentPart.is_active == True, + EquipmentPart.id != part_id, + ) + if (await db.execute(dupe_stmt)).scalar_one_or_none(): + raise HTTPException( + status_code=409, + detail=f"같은 이름의 부품 '{body.name}'이 이미 존재합니다.", + ) + part.name = body.name + + if body.part_number is not None: + part.part_number = body.part_number + if body.category is not None: + part.category = body.category + if body.lifecycle_type is not None: + if body.lifecycle_type not in ("hours", "count", "date"): + raise HTTPException( + status_code=400, + detail="lifecycle_type은 hours, count, date 중 하나여야 합니다.", + ) + part.lifecycle_type = body.lifecycle_type + if body.lifecycle_limit is not None: + part.lifecycle_limit = body.lifecycle_limit + if body.alarm_threshold is not None: + part.alarm_threshold = body.alarm_threshold + if body.counter_source is not None: + if body.counter_source not in ("auto_plc", "auto_time", "manual"): + raise HTTPException( + status_code=400, + detail="counter_source는 auto_plc, auto_time, manual 중 하나여야 합니다.", + ) + part.counter_source = body.counter_source + + await db.commit() + await db.refresh(part) + + return _part_to_dict(part) + + +@router.delete("/api/{tenant_id}/parts/{part_id}") +async def delete_part( + tenant_id: str = Path(...), + part_id: UUID = Path(...), + current_user: TokenData = Depends(require_auth), + db: AsyncSession = Depends(get_db), +): + verify_tenant_access(tenant_id, current_user) + + stmt = select(EquipmentPart).where( + EquipmentPart.id == part_id, EquipmentPart.tenant_id == tenant_id + ) + result = await db.execute(stmt) + part = result.scalar_one_or_none() + + if not part: + raise HTTPException(status_code=404, detail="부품을 찾을 수 없습니다.") + + part.is_active = False + await db.commit() + + return {"status": "success", "message": "부품이 비활성화되었습니다."} diff --git a/src/api/machines.py b/src/api/machines.py new file mode 100644 index 0000000..9d0975a --- /dev/null +++ b/src/api/machines.py @@ -0,0 +1,230 @@ +from typing import List, Optional +from uuid import UUID + +from fastapi import APIRouter, HTTPException, Depends, Path +from pydantic import BaseModel +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from src.database.config import get_db +from src.database.models import Machine, EquipmentPart +from src.auth.models import TokenData +from src.auth.dependencies import require_auth, verify_tenant_access + +router = APIRouter(prefix="/api/{tenant_id}/machines", tags=["machines"]) + + +class MachineCreate(BaseModel): + name: str + equipment_code: str = "" + model: Optional[str] = None + + +class MachineUpdate(BaseModel): + name: Optional[str] = None + equipment_code: Optional[str] = None + model: Optional[str] = None + + +class MachineResponse(BaseModel): + id: str + tenant_id: str + name: str + equipment_code: str + model: Optional[str] + parts_count: int = 0 + created_at: Optional[str] = None + updated_at: Optional[str] = None + + model_config = {"from_attributes": True} + + +class MachineDetailResponse(MachineResponse): + parts: List[dict] = [] + + +def _format_ts(val) -> Optional[str]: + if val is None: + return None + return val.isoformat() if hasattr(val, "isoformat") else str(val) + + +def _machine_to_response(m: Machine, parts_count: int = 0) -> MachineResponse: + return MachineResponse( + id=str(m.id), + tenant_id=str(m.tenant_id), + name=str(m.name), + equipment_code=str(m.equipment_code or ""), + model=str(m.model) if m.model else None, + parts_count=parts_count, + created_at=_format_ts(m.created_at), + updated_at=_format_ts(m.updated_at), + ) + + +@router.get("") +async def list_machines( + tenant_id: str = Path(...), + current_user: TokenData = Depends(require_auth), + db: AsyncSession = Depends(get_db), +): + verify_tenant_access(tenant_id, current_user) + + stmt = ( + select(Machine, func.count(EquipmentPart.id).label("parts_count")) + .outerjoin( + EquipmentPart, + (EquipmentPart.machine_id == Machine.id) + & (EquipmentPart.is_active == True), + ) + .where(Machine.tenant_id == tenant_id) + .group_by(Machine.id) + .order_by(Machine.name) + ) + result = await db.execute(stmt) + rows = result.all() + + return {"machines": [_machine_to_response(m, count) for m, count in rows]} + + +@router.post("") +async def create_machine( + body: MachineCreate, + tenant_id: str = Path(...), + current_user: TokenData = Depends(require_auth), + db: AsyncSession = Depends(get_db), +): + verify_tenant_access(tenant_id, current_user) + + machine = Machine( + tenant_id=tenant_id, + name=body.name, + equipment_code=body.equipment_code, + model=body.model, + ) + db.add(machine) + await db.commit() + await db.refresh(machine) + + return _machine_to_response(machine, 0) + + +@router.get("/{machine_id}") +async def get_machine( + tenant_id: str = Path(...), + machine_id: UUID = Path(...), + current_user: TokenData = Depends(require_auth), + db: AsyncSession = Depends(get_db), +): + verify_tenant_access(tenant_id, current_user) + + stmt = ( + select(Machine) + .options(selectinload(Machine.parts)) + .where(Machine.id == machine_id, Machine.tenant_id == tenant_id) + ) + result = await db.execute(stmt) + machine = result.scalar_one_or_none() + + if not machine: + raise HTTPException(status_code=404, detail="설비를 찾을 수 없습니다.") + + active_parts = [p for p in machine.parts if p.is_active] + + parts_data = [ + { + "id": str(p.id), + "name": str(p.name), + "part_number": str(p.part_number) if p.part_number else None, + "category": str(p.category) if p.category else None, + "lifecycle_type": str(p.lifecycle_type), + "lifecycle_limit": float(p.lifecycle_limit), + "alarm_threshold": float(p.alarm_threshold or 90.0), + "counter_source": str(p.counter_source or "manual"), + "installed_at": _format_ts(p.installed_at), + "is_active": bool(p.is_active), + } + for p in active_parts + ] + + resp = MachineDetailResponse( + id=str(machine.id), + tenant_id=str(machine.tenant_id), + name=str(machine.name), + equipment_code=str(machine.equipment_code or ""), + model=str(machine.model) if machine.model else None, + parts_count=len(active_parts), + parts=parts_data, + created_at=_format_ts(machine.created_at), + updated_at=_format_ts(machine.updated_at), + ) + return resp + + +@router.put("/{machine_id}") +async def update_machine( + body: MachineUpdate, + tenant_id: str = Path(...), + machine_id: UUID = Path(...), + current_user: TokenData = Depends(require_auth), + db: AsyncSession = Depends(get_db), +): + verify_tenant_access(tenant_id, current_user) + + stmt = select(Machine).where( + Machine.id == machine_id, Machine.tenant_id == tenant_id + ) + result = await db.execute(stmt) + machine = result.scalar_one_or_none() + + if not machine: + raise HTTPException(status_code=404, detail="설비를 찾을 수 없습니다.") + + if body.name is not None: + machine.name = body.name + if body.equipment_code is not None: + machine.equipment_code = body.equipment_code + if body.model is not None: + machine.model = body.model + + await db.commit() + await db.refresh(machine) + + return _machine_to_response(machine) + + +@router.delete("/{machine_id}") +async def delete_machine( + tenant_id: str = Path(...), + machine_id: UUID = Path(...), + current_user: TokenData = Depends(require_auth), + db: AsyncSession = Depends(get_db), +): + verify_tenant_access(tenant_id, current_user) + + stmt = select(Machine).where( + Machine.id == machine_id, Machine.tenant_id == tenant_id + ) + result = await db.execute(stmt) + machine = result.scalar_one_or_none() + + if not machine: + raise HTTPException(status_code=404, detail="설비를 찾을 수 없습니다.") + + parts_stmt = select(func.count(EquipmentPart.id)).where( + EquipmentPart.machine_id == machine_id, + EquipmentPart.is_active == True, + ) + parts_count = (await db.execute(parts_stmt)).scalar() or 0 + + if parts_count > 0: + raise HTTPException( + status_code=409, + detail=f"활성 부품이 {parts_count}개 있어 삭제할 수 없습니다. 먼저 부품을 제거해주세요.", + ) + + await db.delete(machine) + await db.commit() + + return {"status": "success", "message": "설비가 삭제되었습니다."} diff --git a/src/auth/__init__.py b/src/auth/__init__.py new file mode 100644 index 0000000..ebb38c9 --- /dev/null +++ b/src/auth/__init__.py @@ -0,0 +1,16 @@ +from src.auth.jwt_handler import create_access_token, decode_access_token +from src.auth.password import hash_password, verify_password +from src.auth.dependencies import get_current_user, require_auth, require_superadmin +from src.auth.router import router as auth_router, admin_router as auth_admin_router + +__all__ = [ + "create_access_token", + "decode_access_token", + "hash_password", + "verify_password", + "get_current_user", + "require_auth", + "require_superadmin", + "auth_router", + "auth_admin_router", +] diff --git a/src/auth/dependencies.py b/src/auth/dependencies.py new file mode 100644 index 0000000..3e47d9f --- /dev/null +++ b/src/auth/dependencies.py @@ -0,0 +1,69 @@ +from typing import Optional + +from fastapi import Depends, HTTPException, Request +from sqlalchemy.ext.asyncio import AsyncSession + +from src.database.config import get_db +from src.auth.jwt_handler import decode_access_token +from src.auth.models import TokenData +from src.auth import service as auth_service + + +async def get_current_user( + request: Request, + db: AsyncSession = Depends(get_db), +) -> Optional[TokenData]: + auth_header = request.headers.get("Authorization") + if not auth_header or not auth_header.startswith("Bearer "): + return None + + token = auth_header.split(" ", 1)[1] + payload = decode_access_token(token) + if not payload: + return None + + user = await auth_service.get_user_by_id(db, payload.get("user_id", "")) + if not user or not bool(user.is_active): + return None + + return TokenData( + user_id=str(user.id), + email=str(user.email), + role=str(user.role), + tenant_id=str(user.tenant_id) if user.tenant_id is not None else None, + ) + + +async def require_auth( + current_user: Optional[TokenData] = Depends(get_current_user), +) -> TokenData: + if not current_user: + raise HTTPException(status_code=401, detail="인증이 필요합니다.") + return current_user + + +async def require_superadmin( + current_user: TokenData = Depends(require_auth), +) -> TokenData: + if current_user.role != "superadmin": + raise HTTPException(status_code=403, detail="관리자 권한이 필요합니다.") + return current_user + + +def verify_tenant_access(tenant_id: str, current_user: TokenData) -> None: + if current_user.role == "superadmin": + return + if current_user.tenant_id != tenant_id: + raise HTTPException( + status_code=403, detail="해당 테넌트에 대한 접근 권한이 없습니다." + ) + + +class TenantAccessChecker: + async def __call__( + self, + tenant_id: str, + current_user: TokenData = Depends(require_auth), + ) -> TokenData: + verify_tenant_access(tenant_id, current_user) + return current_user diff --git a/src/auth/jwt_handler.py b/src/auth/jwt_handler.py new file mode 100644 index 0000000..ed4f993 --- /dev/null +++ b/src/auth/jwt_handler.py @@ -0,0 +1,23 @@ +import os +from datetime import datetime, timedelta, timezone +from typing import Optional + +import jwt + +SECRET_KEY = os.getenv("JWT_SECRET_KEY", "your-super-secret-key") +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_HOURS = 24 + + +def create_access_token(data: dict) -> str: + to_encode = data.copy() + expire = datetime.now(timezone.utc) + timedelta(hours=ACCESS_TOKEN_EXPIRE_HOURS) + to_encode.update({"exp": expire}) + return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + + +def decode_access_token(token: str) -> Optional[dict]: + try: + return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + except (jwt.ExpiredSignatureError, jwt.InvalidTokenError): + return None diff --git a/src/auth/models.py b/src/auth/models.py new file mode 100644 index 0000000..a188e9d --- /dev/null +++ b/src/auth/models.py @@ -0,0 +1,46 @@ +from typing import Optional + +from pydantic import BaseModel + + +class UserLogin(BaseModel): + email: str + password: str + + +class UserCreate(BaseModel): + email: str + password: str + name: str + role: str = "user" + tenant_id: Optional[str] = None + + +class UserUpdate(BaseModel): + name: Optional[str] = None + role: Optional[str] = None + is_active: Optional[bool] = None + + +class UserResponse(BaseModel): + id: str + email: str + name: str + role: str + tenant_id: Optional[str] = None + is_active: bool + + model_config = {"from_attributes": True} + + +class Token(BaseModel): + access_token: str + token_type: str = "bearer" + user: UserResponse + + +class TokenData(BaseModel): + user_id: str + email: str + role: str + tenant_id: Optional[str] = None diff --git a/src/auth/password.py b/src/auth/password.py new file mode 100644 index 0000000..2af1683 --- /dev/null +++ b/src/auth/password.py @@ -0,0 +1,9 @@ +import bcrypt + + +def hash_password(password: str) -> str: + return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8") + + +def verify_password(password: str, hashed: str) -> bool: + return bcrypt.checkpw(password.encode("utf-8"), hashed.encode("utf-8")) diff --git a/src/auth/router.py b/src/auth/router.py new file mode 100644 index 0000000..9fc3950 --- /dev/null +++ b/src/auth/router.py @@ -0,0 +1,102 @@ +from typing import List + +from fastapi import APIRouter, HTTPException, Response, Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from src.database.config import get_db +from src.auth.models import UserLogin, UserCreate, UserResponse, Token, TokenData +from src.auth import service as auth_service +from src.auth.dependencies import require_auth, require_superadmin + +router = APIRouter(prefix="/api/auth", tags=["auth"]) +admin_router = APIRouter(prefix="/api/admin", tags=["admin"]) + + +@router.post("/login", response_model=Token) +async def login( + credentials: UserLogin, + response: Response, + db: AsyncSession = Depends(get_db), +): + """로그인 - 이메일/비밀번호로 JWT 토큰 발급""" + result = await auth_service.login(db, credentials.email, credentials.password) + if not result: + raise HTTPException( + status_code=401, detail="이메일 또는 비밀번호가 올바르지 않습니다." + ) + + response.set_cookie( + key="access_token", + value=result.access_token, + httponly=True, + max_age=86400, + samesite="lax", + ) + return result + + +@router.post("/logout") +async def logout(response: Response): + """로그아웃""" + response.delete_cookie(key="access_token") + return {"status": "success", "message": "로그아웃되었습니다."} + + +@router.get("/me", response_model=UserResponse) +async def get_current_user_info( + current_user: TokenData = Depends(require_auth), + db: AsyncSession = Depends(get_db), +): + """현재 로그인한 사용자 정보""" + user = await auth_service.get_user_by_id(db, current_user.user_id) + if not user: + raise HTTPException(status_code=404, detail="사용자를 찾을 수 없습니다.") + return UserResponse( + id=str(user.id), + email=str(user.email), + name=str(user.name), + role=str(user.role), + tenant_id=str(user.tenant_id) if user.tenant_id is not None else None, + is_active=bool(user.is_active), + ) + + +@admin_router.get("/users", response_model=List[UserResponse]) +async def list_all_users( + current_user: TokenData = Depends(require_superadmin), + db: AsyncSession = Depends(get_db), +): + """전체 사용자 목록 (superadmin 전용)""" + users = await auth_service.list_users(db) + return [ + UserResponse( + id=str(u.id), + email=str(u.email), + name=str(u.name), + role=str(u.role), + tenant_id=str(u.tenant_id) if u.tenant_id is not None else None, + is_active=bool(u.is_active), + ) + for u in users + ] + + +@admin_router.post("/users", response_model=UserResponse) +async def create_user( + user_data: UserCreate, + current_user: TokenData = Depends(require_superadmin), + db: AsyncSession = Depends(get_db), +): + """사용자 생성 (superadmin 전용)""" + try: + user = await auth_service.create_user(db, user_data) + return UserResponse( + id=str(user.id), + email=str(user.email), + name=str(user.name), + role=str(user.role), + tenant_id=str(user.tenant_id) if user.tenant_id is not None else None, + is_active=bool(user.is_active), + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) diff --git a/src/auth/service.py b/src/auth/service.py new file mode 100644 index 0000000..6f067db --- /dev/null +++ b/src/auth/service.py @@ -0,0 +1,75 @@ +import uuid +from typing import Optional, List + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.database.models import User +from src.auth.jwt_handler import create_access_token +from src.auth.password import hash_password, verify_password +from src.auth.models import UserCreate, UserResponse, Token, TokenData + + +async def login(db: AsyncSession, email: str, password: str) -> Optional[Token]: + user = await get_user_by_email(db, email) + if not user or not verify_password(password, str(user.password_hash)): + return None + if not bool(user.is_active): + return None + + token_data = { + "user_id": str(user.id), + "email": str(user.email), + "role": str(user.role), + "tenant_id": str(user.tenant_id) if user.tenant_id is not None else None, + } + access_token = create_access_token(token_data) + + return Token( + access_token=access_token, + user=UserResponse( + id=str(user.id), + email=str(user.email), + name=str(user.name), + role=str(user.role), + tenant_id=str(user.tenant_id) if user.tenant_id is not None else None, + is_active=bool(user.is_active), + ), + ) + + +async def get_user_by_id(db: AsyncSession, user_id: str) -> Optional[User]: + result = await db.execute(select(User).where(User.id == user_id)) + return result.scalar_one_or_none() + + +async def get_user_by_email(db: AsyncSession, email: str) -> Optional[User]: + result = await db.execute(select(User).where(User.email == email)) + return result.scalar_one_or_none() + + +async def create_user(db: AsyncSession, data: UserCreate) -> User: + existing = await get_user_by_email(db, data.email) + if existing: + raise ValueError(f"Email already exists: {data.email}") + + user = User( + id=uuid.uuid4(), + email=data.email, + password_hash=hash_password(data.password), + name=data.name, + role=data.role, + tenant_id=data.tenant_id, + ) + db.add(user) + await db.commit() + await db.refresh(user) + return user + + +async def list_users(db: AsyncSession, tenant_id: Optional[str] = None) -> List[User]: + stmt = select(User) + if tenant_id: + stmt = stmt.where(User.tenant_id == tenant_id) + result = await db.execute(stmt.order_by(User.created_at.desc())) + return list(result.scalars().all()) diff --git a/src/database/__init__.py b/src/database/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/database/config.py b/src/database/config.py new file mode 100644 index 0000000..f6d8aa2 --- /dev/null +++ b/src/database/config.py @@ -0,0 +1,39 @@ +"""PostgreSQL async 데이터베이스 설정""" + +import os +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker +from sqlalchemy.orm import declarative_base + +DATABASE_URL = os.getenv( + "DATABASE_URL", + "postgresql+asyncpg://factoryops:factoryops@localhost:5432/factoryops_v2", +) + +engine = create_async_engine( + DATABASE_URL, + pool_size=10, + max_overflow=20, + pool_pre_ping=True, + echo=os.getenv("SQL_ECHO", "false").lower() == "true", +) + +AsyncSessionLocal = async_sessionmaker( + engine, class_=AsyncSession, expire_on_commit=False +) + +Base = declarative_base() + + +async def get_db(): + async with AsyncSessionLocal() as session: + try: + yield session + finally: + await session.close() + + +async def init_db(): + from src.database.models import Base as _Base # noqa: F811 + + async with engine.begin() as conn: + await conn.run_sync(_Base.metadata.create_all) diff --git a/src/database/models.py b/src/database/models.py new file mode 100644 index 0000000..5f44505 --- /dev/null +++ b/src/database/models.py @@ -0,0 +1,166 @@ +import uuid +from datetime import datetime, timezone + +from sqlalchemy import ( + Column, + String, + Boolean, + Float, + Integer, + ForeignKey, + Index, + Text, + UniqueConstraint, + text, +) +from sqlalchemy.dialects.postgresql import UUID, JSONB, TIMESTAMP +from sqlalchemy.orm import relationship + +from src.database.config import Base + + +def utcnow(): + return datetime.now(timezone.utc) + + +class User(Base): + __tablename__ = "users" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + email = Column(String(255), unique=True, nullable=False) + password_hash = Column(String(255), nullable=False) + name = Column(String(100), nullable=False) + role = Column(String(20), nullable=False) # superadmin | tenant_admin | user + tenant_id = Column(String(50), ForeignKey("tenants.id"), nullable=True) + is_active = Column(Boolean, default=True) + created_at = Column(TIMESTAMP(timezone=True), default=utcnow) + updated_at = Column(TIMESTAMP(timezone=True), default=utcnow, onupdate=utcnow) + + tenant = relationship("Tenant", back_populates="users") + + +class Tenant(Base): + __tablename__ = "tenants" + + id = Column(String(50), primary_key=True) + name = Column(String(100), nullable=False) + industry_type = Column(String(50), default="general") + is_active = Column(Boolean, default=True) + enabled_modules = Column(JSONB, nullable=True) + workflow_config = Column(JSONB, nullable=True) + created_at = Column(TIMESTAMP(timezone=True), default=utcnow) + + users = relationship("User", back_populates="tenant") + machines = relationship("Machine", back_populates="tenant") + + +class Machine(Base): + __tablename__ = "machines" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + tenant_id = Column(String(50), ForeignKey("tenants.id"), nullable=False) + name = Column(String(100), nullable=False) + equipment_code = Column(String(50), default="") + model = Column(String(100), nullable=True) + created_at = Column(TIMESTAMP(timezone=True), default=utcnow) + updated_at = Column(TIMESTAMP(timezone=True), default=utcnow, onupdate=utcnow) + + tenant = relationship("Tenant", back_populates="machines") + parts = relationship( + "EquipmentPart", back_populates="machine", cascade="all, delete-orphan" + ) + + __table_args__ = (Index("ix_machines_tenant_id", "tenant_id"),) + + +class EquipmentPart(Base): + __tablename__ = "equipment_parts" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + tenant_id = Column(String(50), ForeignKey("tenants.id"), nullable=False) + machine_id = Column(UUID(as_uuid=True), ForeignKey("machines.id"), nullable=False) + name = Column(String(100), nullable=False) + part_number = Column(String(50), nullable=True) + category = Column(String(50), nullable=True) + lifecycle_type = Column(String(20), nullable=False) # hours | count | date + lifecycle_limit = Column(Float, nullable=False) + alarm_threshold = Column(Float, default=90.0) + counter_source = Column( + String(20), default="manual" + ) # auto_plc | auto_time | manual + installed_at = Column(TIMESTAMP(timezone=True), nullable=True) + is_active = Column(Boolean, default=True) + created_at = Column(TIMESTAMP(timezone=True), default=utcnow) + updated_at = Column(TIMESTAMP(timezone=True), default=utcnow, onupdate=utcnow) + + tenant = relationship("Tenant") + machine = relationship("Machine", back_populates="parts") + counter = relationship( + "PartCounter", + back_populates="equipment_part", + uselist=False, + cascade="all, delete-orphan", + ) + replacement_logs = relationship( + "PartReplacementLog", + back_populates="equipment_part", + cascade="all, delete-orphan", + ) + + __table_args__ = ( + Index( + "uq_equipment_part_active_tenant_machine_name", + "tenant_id", + "machine_id", + "name", + unique=True, + postgresql_where=text("is_active = true"), + ), + Index("ix_equipment_parts_tenant_id", "tenant_id"), + Index("ix_equipment_parts_tenant_machine", "tenant_id", "machine_id"), + ) + + +class PartCounter(Base): + __tablename__ = "part_counters" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + tenant_id = Column(String(50), ForeignKey("tenants.id"), nullable=False) + equipment_part_id = Column( + UUID(as_uuid=True), + ForeignKey("equipment_parts.id"), + unique=True, + nullable=False, + ) + current_value = Column(Float, default=0) + lifecycle_pct = Column(Float, default=0) + last_reset_at = Column(TIMESTAMP(timezone=True), nullable=False) + last_updated_at = Column(TIMESTAMP(timezone=True), nullable=False) + version = Column(Integer, default=0) + + equipment_part = relationship("EquipmentPart", back_populates="counter") + + __table_args__ = (Index("ix_part_counters_tenant_id", "tenant_id"),) + + +class PartReplacementLog(Base): + __tablename__ = "part_replacement_logs" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + tenant_id = Column(String(50), ForeignKey("tenants.id"), nullable=False) + equipment_part_id = Column( + UUID(as_uuid=True), ForeignKey("equipment_parts.id"), nullable=False + ) + replaced_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True) + replaced_at = Column(TIMESTAMP(timezone=True), nullable=False) + counter_at_replacement = Column(Float, nullable=False) + lifecycle_pct_at_replacement = Column(Float, nullable=False) + reason = Column(String(200), nullable=True) + notes = Column(Text, nullable=True) + + equipment_part = relationship("EquipmentPart", back_populates="replacement_logs") + + __table_args__ = ( + Index("ix_part_replacement_tenant_part", "tenant_id", "equipment_part_id"), + Index("ix_part_replacement_tenant_date", "tenant_id", "replaced_at"), + ) diff --git a/src/services/__init__.py b/src/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/tenant/__init__.py b/src/tenant/__init__.py new file mode 100644 index 0000000..3a2b98a --- /dev/null +++ b/src/tenant/__init__.py @@ -0,0 +1,21 @@ +from src.tenant.manager import ( + get_tenant, + tenant_exists, + create_tenant, + list_tenants, + update_tenant, + validate_tenant_id, + TenantNotFoundError, + InvalidTenantIdError, +) + +__all__ = [ + "get_tenant", + "tenant_exists", + "create_tenant", + "list_tenants", + "update_tenant", + "validate_tenant_id", + "TenantNotFoundError", + "InvalidTenantIdError", +] diff --git a/src/tenant/manager.py b/src/tenant/manager.py new file mode 100644 index 0000000..0cb3ef5 --- /dev/null +++ b/src/tenant/manager.py @@ -0,0 +1,136 @@ +import re +from typing import Any, Dict, List, Optional + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.database.models import Tenant + + +def _format_timestamp(val: Any) -> Optional[str]: + if val is None: + return None + return str(val.isoformat()) if hasattr(val, "isoformat") else str(val) + + +TENANT_ID_PATTERN = re.compile(r"^[a-z0-9][a-z0-9-]{1,30}[a-z0-9]$") + + +class TenantNotFoundError(Exception): + pass + + +class InvalidTenantIdError(Exception): + pass + + +def validate_tenant_id(tenant_id: str) -> bool: + if not tenant_id or len(tenant_id) < 3 or len(tenant_id) > 32: + return False + return bool(TENANT_ID_PATTERN.match(tenant_id)) + + +async def get_tenant(db: AsyncSession, tenant_id: str) -> Dict: + if not validate_tenant_id(tenant_id): + raise InvalidTenantIdError(f"유효하지 않은 테넌트 ID: {tenant_id}") + + result = await db.execute(select(Tenant).where(Tenant.id == tenant_id)) + tenant = result.scalar_one_or_none() + if not tenant: + raise TenantNotFoundError(f"테넌트를 찾을 수 없습니다: {tenant_id}") + + return { + "id": str(tenant.id), + "name": str(tenant.name), + "industry_type": str(tenant.industry_type), + "is_active": bool(tenant.is_active), + "created_at": _format_timestamp(tenant.created_at), + } + + +async def tenant_exists(db: AsyncSession, tenant_id: str) -> bool: + if not validate_tenant_id(tenant_id): + return False + result = await db.execute(select(Tenant).where(Tenant.id == tenant_id)) + return result.scalar_one_or_none() is not None + + +async def create_tenant( + db: AsyncSession, + tenant_id: str, + name: str, + industry_type: str = "general", + enabled_modules: Optional[dict] = None, + workflow_config: Optional[dict] = None, +) -> Dict: + if not validate_tenant_id(tenant_id): + raise InvalidTenantIdError(f"유효하지 않은 테넌트 ID: {tenant_id}") + + existing = await tenant_exists(db, tenant_id) + if existing: + raise ValueError(f"이미 존재하는 테넌트입니다: {tenant_id}") + + tenant = Tenant( + id=tenant_id, + name=name, + industry_type=industry_type, + enabled_modules=enabled_modules, + workflow_config=workflow_config, + ) + db.add(tenant) + await db.commit() + await db.refresh(tenant) + + return { + "id": str(tenant.id), + "name": str(tenant.name), + "industry_type": str(tenant.industry_type), + "is_active": bool(tenant.is_active), + "created_at": _format_timestamp(tenant.created_at), + } + + +async def list_tenants(db: AsyncSession) -> List[Dict]: + result = await db.execute(select(Tenant).order_by(Tenant.created_at.desc())) + tenants = result.scalars().all() + return [ + { + "id": str(t.id), + "name": str(t.name), + "industry_type": str(t.industry_type), + "is_active": bool(t.is_active), + "created_at": _format_timestamp(t.created_at), + } + for t in tenants + ] + + +async def update_tenant( + db: AsyncSession, + tenant_id: str, + name: Optional[str] = None, + is_active: Optional[bool] = None, +) -> Dict: + if not validate_tenant_id(tenant_id): + raise InvalidTenantIdError(f"유효하지 않은 테넌트 ID: {tenant_id}") + + result = await db.execute(select(Tenant).where(Tenant.id == tenant_id)) + tenant = result.scalar_one_or_none() + if not tenant: + raise TenantNotFoundError(f"테넌트를 찾을 수 없습니다: {tenant_id}") + + if name is not None: + tenant.name = name # type: ignore[assignment] + if is_active is not None: + tenant.is_active = is_active # type: ignore[assignment] + + await db.commit() + await db.refresh(tenant) + + return { + "id": str(tenant.id), + "name": str(tenant.name), + "industry_type": str(tenant.industry_type), + "is_active": bool(tenant.is_active), + "created_at": _format_timestamp(tenant.created_at), + } diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..c24a174 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,98 @@ +import os +import uuid + +import pytest +import pytest_asyncio +from dotenv import load_dotenv + +load_dotenv() + +from httpx import AsyncClient, ASGITransport +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker + +from src.database.config import get_db +from src.database.models import Base, Tenant, User +from src.auth.password import hash_password + +TEST_DATABASE_URL = os.getenv( + "TEST_DATABASE_URL", + "postgresql+asyncpg://factoryops:factoryops@localhost:5432/factoryops_v2_test", +) + + +@pytest_asyncio.fixture(scope="function") +async def db_session(): + engine = create_async_engine(TEST_DATABASE_URL, echo=False, pool_size=5) + + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + session_factory = async_sessionmaker( + engine, class_=AsyncSession, expire_on_commit=False + ) + + async with session_factory() as session: + yield session + + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + + await engine.dispose() + + +@pytest_asyncio.fixture(scope="function") +async def client(db_session: AsyncSession): + from main import app + + async def override_get_db(): + yield db_session + + app.dependency_overrides[get_db] = override_get_db + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + yield ac + + app.dependency_overrides.clear() + + +@pytest_asyncio.fixture(scope="function") +async def seeded_db(db_session: AsyncSession): + db_session.add( + Tenant(id="test-co", name="Test Company", industry_type="manufacturing") + ) + db_session.add(Tenant(id="other-co", name="Other Company", industry_type="general")) + await db_session.commit() + + db_session.add( + User( + id=uuid.uuid4(), + email="super@test.com", + password_hash=hash_password("pass1234"), + name="Super Admin", + role="superadmin", + tenant_id=None, + ) + ) + db_session.add( + User( + id=uuid.uuid4(), + email="admin@test-co.com", + password_hash=hash_password("pass1234"), + name="Tenant Admin", + role="tenant_admin", + tenant_id="test-co", + ) + ) + await db_session.commit() + return db_session + + +async def get_auth_headers( + client: AsyncClient, email: str = "super@test.com", password: str = "pass1234" +) -> dict: + resp = await client.post( + "/api/auth/login", json={"email": email, "password": password} + ) + token = resp.json()["access_token"] + return {"Authorization": f"Bearer {token}"} diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..5b3ea0c --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,108 @@ +import pytest +from httpx import AsyncClient + + +@pytest.mark.asyncio +async def test_health_check(client: AsyncClient): + response = await client.get("/api/health") + assert response.status_code == 200 + data = response.json() + assert data["status"] == "ok" + assert data["version"] == "2.0.0" + + +@pytest.mark.asyncio +async def test_login_invalid_credentials(client: AsyncClient): + response = await client.post( + "/api/auth/login", + json={"email": "nonexistent@test.com", "password": "wrongpass"}, + ) + assert response.status_code == 401 + + +@pytest.mark.asyncio +async def test_get_me_unauthenticated(client: AsyncClient): + response = await client.get("/api/auth/me") + assert response.status_code == 401 + + +@pytest.mark.asyncio +async def test_admin_users_unauthenticated(client: AsyncClient): + response = await client.get("/api/admin/users") + assert response.status_code == 401 + + +@pytest.mark.asyncio +async def test_create_user_and_login(client: AsyncClient, db_session): + from src.auth.password import hash_password + from src.database.models import User, Tenant + import uuid + + db_session.add(Tenant(id="test-tenant", name="Test Tenant")) + await db_session.commit() + + db_session.add( + User( + id=uuid.uuid4(), + email="admin@test.com", + password_hash=hash_password("password123"), + name="Admin", + role="superadmin", + tenant_id=None, + ) + ) + await db_session.commit() + + login_resp = await client.post( + "/api/auth/login", + json={"email": "admin@test.com", "password": "password123"}, + ) + assert login_resp.status_code == 200 + token_data = login_resp.json() + assert "access_token" in token_data + assert token_data["user"]["email"] == "admin@test.com" + assert token_data["user"]["role"] == "superadmin" + + token = token_data["access_token"] + + me_resp = await client.get( + "/api/auth/me", headers={"Authorization": f"Bearer {token}"} + ) + assert me_resp.status_code == 200 + assert me_resp.json()["email"] == "admin@test.com" + + create_resp = await client.post( + "/api/admin/users", + json={ + "email": "user1@test.com", + "password": "userpass", + "name": "User 1", + "role": "user", + "tenant_id": "test-tenant", + }, + headers={"Authorization": f"Bearer {token}"}, + ) + assert create_resp.status_code == 200 + created = create_resp.json() + assert created["email"] == "user1@test.com" + assert created["tenant_id"] == "test-tenant" + + users_resp = await client.get( + "/api/admin/users", headers={"Authorization": f"Bearer {token}"} + ) + assert users_resp.status_code == 200 + users = users_resp.json() + assert len(users) == 2 + + +@pytest.mark.asyncio +async def test_logout(client: AsyncClient): + response = await client.post("/api/auth/logout") + assert response.status_code == 200 + assert response.json()["status"] == "success" + + +@pytest.mark.asyncio +async def test_tenants_unauthenticated(client: AsyncClient): + response = await client.get("/api/tenants") + assert response.status_code == 401 diff --git a/tests/test_equipment_parts.py b/tests/test_equipment_parts.py new file mode 100644 index 0000000..9cc986d --- /dev/null +++ b/tests/test_equipment_parts.py @@ -0,0 +1,225 @@ +import pytest +from httpx import AsyncClient +from tests.conftest import get_auth_headers + + +async def _create_machine( + client: AsyncClient, headers: dict, tenant_id: str = "test-co" +) -> str: + resp = await client.post( + f"/api/{tenant_id}/machines", + json={"name": "테스트 설비", "equipment_code": "T-001"}, + headers=headers, + ) + return resp.json()["id"] + + +@pytest.mark.asyncio +async def test_create_part(client: AsyncClient, seeded_db): + headers = await get_auth_headers(client) + machine_id = await _create_machine(client, headers) + + resp = await client.post( + f"/api/test-co/machines/{machine_id}/parts", + json={ + "name": "상부 금형", + "category": "mold", + "lifecycle_type": "count", + "lifecycle_limit": 10000, + "alarm_threshold": 90.0, + "counter_source": "auto_plc", + "installed_at": "2026-02-01T00:00:00+09:00", + }, + headers=headers, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["name"] == "상부 금형" + assert data["lifecycle_type"] == "count" + assert data["lifecycle_limit"] == 10000 + assert data["counter"]["current_value"] == 0 + assert data["counter"]["lifecycle_pct"] == 0 + + +@pytest.mark.asyncio +async def test_create_part_auto_creates_counter(client: AsyncClient, seeded_db): + headers = await get_auth_headers(client) + machine_id = await _create_machine(client, headers) + + resp = await client.post( + f"/api/test-co/machines/{machine_id}/parts", + json={ + "name": "히터", + "lifecycle_type": "hours", + "lifecycle_limit": 5000, + "installed_at": "2026-01-15T00:00:00Z", + }, + headers=headers, + ) + assert resp.status_code == 200 + data = resp.json() + assert "counter" in data + assert data["counter"]["current_value"] == 0 + assert data["counter"]["version"] == 0 + + +@pytest.mark.asyncio +async def test_duplicate_part_name_rejected(client: AsyncClient, seeded_db): + headers = await get_auth_headers(client) + machine_id = await _create_machine(client, headers) + + part_data = { + "name": "동일 부품", + "lifecycle_type": "count", + "lifecycle_limit": 1000, + "installed_at": "2026-02-01T00:00:00Z", + } + + resp1 = await client.post( + f"/api/test-co/machines/{machine_id}/parts", json=part_data, headers=headers + ) + assert resp1.status_code == 200 + + resp2 = await client.post( + f"/api/test-co/machines/{machine_id}/parts", json=part_data, headers=headers + ) + assert resp2.status_code == 409 + + +@pytest.mark.asyncio +async def test_list_parts(client: AsyncClient, seeded_db): + headers = await get_auth_headers(client) + machine_id = await _create_machine(client, headers) + + for name in ["부품A", "부품B", "부품C"]: + await client.post( + f"/api/test-co/machines/{machine_id}/parts", + json={ + "name": name, + "lifecycle_type": "count", + "lifecycle_limit": 1000, + "installed_at": "2026-02-01T00:00:00Z", + }, + headers=headers, + ) + + resp = await client.get( + f"/api/test-co/machines/{machine_id}/parts", headers=headers + ) + assert resp.status_code == 200 + assert len(resp.json()["parts"]) == 3 + + +@pytest.mark.asyncio +async def test_get_part_detail(client: AsyncClient, seeded_db): + headers = await get_auth_headers(client) + machine_id = await _create_machine(client, headers) + + create_resp = await client.post( + f"/api/test-co/machines/{machine_id}/parts", + json={ + "name": "센서", + "lifecycle_type": "hours", + "lifecycle_limit": 8000, + "installed_at": "2026-02-01T00:00:00Z", + }, + headers=headers, + ) + part_id = create_resp.json()["id"] + + resp = await client.get(f"/api/test-co/parts/{part_id}", headers=headers) + assert resp.status_code == 200 + assert resp.json()["name"] == "센서" + assert resp.json()["lifecycle_limit"] == 8000 + + +@pytest.mark.asyncio +async def test_update_part(client: AsyncClient, seeded_db): + headers = await get_auth_headers(client) + machine_id = await _create_machine(client, headers) + + create_resp = await client.post( + f"/api/test-co/machines/{machine_id}/parts", + json={ + "name": "금형", + "lifecycle_type": "count", + "lifecycle_limit": 5000, + "installed_at": "2026-02-01T00:00:00Z", + }, + headers=headers, + ) + part_id = create_resp.json()["id"] + + resp = await client.put( + f"/api/test-co/parts/{part_id}", + json={"name": "상부 금형", "lifecycle_limit": 8000}, + headers=headers, + ) + assert resp.status_code == 200 + assert resp.json()["name"] == "상부 금형" + assert resp.json()["lifecycle_limit"] == 8000 + + +@pytest.mark.asyncio +async def test_delete_part(client: AsyncClient, seeded_db): + headers = await get_auth_headers(client) + machine_id = await _create_machine(client, headers) + + create_resp = await client.post( + f"/api/test-co/machines/{machine_id}/parts", + json={ + "name": "삭제 부품", + "lifecycle_type": "date", + "lifecycle_limit": 30, + "installed_at": "2026-02-01T00:00:00Z", + }, + headers=headers, + ) + part_id = create_resp.json()["id"] + + resp = await client.delete(f"/api/test-co/parts/{part_id}", headers=headers) + assert resp.status_code == 200 + assert resp.json()["status"] == "success" + + list_resp = await client.get( + f"/api/test-co/machines/{machine_id}/parts", headers=headers + ) + assert len(list_resp.json()["parts"]) == 0 + + +@pytest.mark.asyncio +async def test_invalid_lifecycle_type(client: AsyncClient, seeded_db): + headers = await get_auth_headers(client) + machine_id = await _create_machine(client, headers) + + resp = await client.post( + f"/api/test-co/machines/{machine_id}/parts", + json={ + "name": "잘못된 부품", + "lifecycle_type": "invalid", + "lifecycle_limit": 100, + "installed_at": "2026-02-01T00:00:00Z", + }, + headers=headers, + ) + assert resp.status_code == 400 + + +@pytest.mark.asyncio +async def test_delete_machine_with_parts_rejected(client: AsyncClient, seeded_db): + headers = await get_auth_headers(client) + machine_id = await _create_machine(client, headers) + + await client.post( + f"/api/test-co/machines/{machine_id}/parts", + json={ + "name": "부품", + "lifecycle_type": "count", + "lifecycle_limit": 1000, + "installed_at": "2026-02-01T00:00:00Z", + }, + headers=headers, + ) + + resp = await client.delete(f"/api/test-co/machines/{machine_id}", headers=headers) + assert resp.status_code == 409 diff --git a/tests/test_machines.py b/tests/test_machines.py new file mode 100644 index 0000000..118f683 --- /dev/null +++ b/tests/test_machines.py @@ -0,0 +1,137 @@ +import pytest +from httpx import AsyncClient +from tests.conftest import get_auth_headers + + +@pytest.mark.asyncio +async def test_create_machine(client: AsyncClient, seeded_db): + headers = await get_auth_headers(client) + + resp = await client.post( + "/api/test-co/machines", + json={"name": "1호기", "equipment_code": "TC-001", "model": "Press A"}, + headers=headers, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["name"] == "1호기" + assert data["equipment_code"] == "TC-001" + assert data["tenant_id"] == "test-co" + assert data["parts_count"] == 0 + + +@pytest.mark.asyncio +async def test_list_machines(client: AsyncClient, seeded_db): + headers = await get_auth_headers(client) + + await client.post( + "/api/test-co/machines", + json={"name": "1호기", "equipment_code": "TC-001"}, + headers=headers, + ) + await client.post( + "/api/test-co/machines", + json={"name": "2호기", "equipment_code": "TC-002"}, + headers=headers, + ) + + resp = await client.get("/api/test-co/machines", headers=headers) + assert resp.status_code == 200 + machines = resp.json()["machines"] + assert len(machines) == 2 + + +@pytest.mark.asyncio +async def test_get_machine_detail(client: AsyncClient, seeded_db): + headers = await get_auth_headers(client) + + create_resp = await client.post( + "/api/test-co/machines", + json={"name": "1호기", "equipment_code": "TC-001"}, + headers=headers, + ) + machine_id = create_resp.json()["id"] + + resp = await client.get(f"/api/test-co/machines/{machine_id}", headers=headers) + assert resp.status_code == 200 + data = resp.json() + assert data["name"] == "1호기" + assert data["parts"] == [] + + +@pytest.mark.asyncio +async def test_update_machine(client: AsyncClient, seeded_db): + headers = await get_auth_headers(client) + + create_resp = await client.post( + "/api/test-co/machines", + json={"name": "1호기"}, + headers=headers, + ) + machine_id = create_resp.json()["id"] + + resp = await client.put( + f"/api/test-co/machines/{machine_id}", + json={"name": "1호기 (수정)", "equipment_code": "NEW-001"}, + headers=headers, + ) + assert resp.status_code == 200 + assert resp.json()["name"] == "1호기 (수정)" + assert resp.json()["equipment_code"] == "NEW-001" + + +@pytest.mark.asyncio +async def test_delete_machine(client: AsyncClient, seeded_db): + headers = await get_auth_headers(client) + + create_resp = await client.post( + "/api/test-co/machines", + json={"name": "삭제할 설비"}, + headers=headers, + ) + machine_id = create_resp.json()["id"] + + resp = await client.delete(f"/api/test-co/machines/{machine_id}", headers=headers) + assert resp.status_code == 200 + assert resp.json()["status"] == "success" + + get_resp = await client.get(f"/api/test-co/machines/{machine_id}", headers=headers) + assert get_resp.status_code == 404 + + +@pytest.mark.asyncio +async def test_tenant_isolation(client: AsyncClient, seeded_db): + headers = await get_auth_headers(client) + + await client.post( + "/api/test-co/machines", + json={"name": "test-co 설비"}, + headers=headers, + ) + await client.post( + "/api/other-co/machines", + json={"name": "other-co 설비"}, + headers=headers, + ) + + resp_test = await client.get("/api/test-co/machines", headers=headers) + resp_other = await client.get("/api/other-co/machines", headers=headers) + + assert len(resp_test.json()["machines"]) == 1 + assert len(resp_other.json()["machines"]) == 1 + assert resp_test.json()["machines"][0]["name"] == "test-co 설비" + assert resp_other.json()["machines"][0]["name"] == "other-co 설비" + + +@pytest.mark.asyncio +async def test_machine_unauthenticated(client: AsyncClient, seeded_db): + resp = await client.get("/api/test-co/machines") + assert resp.status_code == 401 + + +@pytest.mark.asyncio +async def test_tenant_access_denied(client: AsyncClient, seeded_db): + headers = await get_auth_headers(client, email="admin@test-co.com") + + resp = await client.get("/api/other-co/machines", headers=headers) + assert resp.status_code == 403