Compare commits

...

9 Commits

19 changed files with 1154 additions and 115 deletions

View File

@@ -4,7 +4,7 @@
- 프로젝트명: 10 Minute Planner 웹 UI - 프로젝트명: 10 Minute Planner 웹 UI
- 기술 스택: Vue 3 + Vite + TailwindCSS + JavaScript - 기술 스택: Vue 3 + Vite + TailwindCSS + JavaScript
- 현재 기준 버전: `v0.1.33` 준비 중 - 현재 기준 버전: `v0.1.41` 준비 중
- Git 원격 저장소: `https://git.sori.studio/zenn/planner.sori.studio.git` - Git 원격 저장소: `https://git.sori.studio/zenn/planner.sori.studio.git`
## 기준 디자인 ## 기준 디자인
@@ -80,6 +80,9 @@
- 현재 `1-UP`은 프레임 자체를 A4 세로에 가깝게 키우고 배율을 크게 올려 빈 여백을 줄이는 방향으로 맞추고 있다. - 현재 `1-UP`은 프레임 자체를 A4 세로에 가깝게 키우고 배율을 크게 올려 빈 여백을 줄이는 방향으로 맞추고 있다.
- 백엔드 초안은 `Fastify + Drizzle + PostgreSQL` 조합으로 전환되었다. - 백엔드 초안은 `Fastify + Drizzle + PostgreSQL` 조합으로 전환되었다.
- 백엔드에는 `/api/auth/signup`, `/api/auth/login`, `/api/auth/me`가 추가되었다. - 백엔드에는 `/api/auth/signup`, `/api/auth/login`, `/api/auth/me`가 추가되었다.
- 백엔드에는 `/api/auth/verification/request`, `/api/auth/verification/confirm` 이메일 인증 토큰 API가 추가되었다.
- 백엔드에는 `/api/auth/password-reset/request`, `/api/auth/password-reset/confirm` 비밀번호 재설정 토큰 API가 추가되었다.
- 백엔드에는 `/api/admin/overview` 관리자 요약 API가 추가되었다.
- 백엔드에는 `/api/planner/:entryDate` 단건 조회/저장과 `/api/planner?from=...&to=...` 범위 조회가 추가되었다. - 백엔드에는 `/api/planner/:entryDate` 단건 조회/저장과 `/api/planner?from=...&to=...` 범위 조회가 추가되었다.
- 비밀번호는 Node 내장 `crypto.scrypt` 기반 해시로 저장하고, 세션 토큰은 `auth_sessions` 테이블에 해시 형태로 저장한다. - 비밀번호는 Node 내장 `crypto.scrypt` 기반 해시로 저장하고, 세션 토큰은 `auth_sessions` 테이블에 해시 형태로 저장한다.
- 초기 실행 시 `backend/src/db/init.js`에서 테이블이 없으면 자동 생성하도록 맞춰두었다. - 초기 실행 시 `backend/src/db/init.js`에서 테이블이 없으면 자동 생성하도록 맞춰두었다.
@@ -180,6 +183,29 @@
- 모바일처럼 좁은 화면에서는 본문 래퍼 패딩을 조금 줄이고, 우측 패널 열기 버튼 문구를 `INFO`로 축약해 밀도를 낮췄다. - 모바일처럼 좁은 화면에서는 본문 래퍼 패딩을 조금 줄이고, 우측 패널 열기 버튼 문구를 `INFO`로 축약해 밀도를 낮췄다.
- 플래너 본문은 작은 화면에서 상단 정보 영역이 세로로 쌓이고, `TIME TABLE`이 아래로 내려가도록 조정했다. - 플래너 본문은 작은 화면에서 상단 정보 영역이 세로로 쌓이고, `TIME TABLE`이 아래로 내려가도록 조정했다.
- 모바일 구간에서는 TASKS / MEMO 행 높이와 좌우 패딩을 조금 줄여 입력 밀도를 낮췄고, 타임테이블은 필요할 때만 최소 가로 스크롤이 생기도록 바뀌었다. - 모바일 구간에서는 TASKS / MEMO 행 높이와 좌우 패딩을 조금 줄여 입력 밀도를 낮췄고, 타임테이블은 필요할 때만 최소 가로 스크롤이 생기도록 바뀌었다.
- 미니 달력은 모바일 구간에서 패딩, 월 이동 버튼, 요일 헤더, 날짜 셀 크기를 한 단계 더 줄여서 카드 내부 밀도를 정리했다.
- 연도 선택 팝오버는 좁은 화면에서 카드 전체 폭을 활용하고, 넓은 화면에서는 기존 우측 드롭다운 폭을 유지한다.
- 플래너 본문 안의 `TOTAL TIME` 라벨도 `총 시간`으로 바꿔서 영어 라벨을 줄였다.
- 사용자 노출 메뉴 문구는 `보기 방식 / 날짜 이동 / 인쇄 / 1페이지 + 정보 / 2페이지 펼침 / 이전 날 / 다음 날 / 1장 인쇄 / 2장 인쇄`처럼 한글 중심으로 정리하기 시작했다.
- 2페이지 펼침 보기 배율 계산에서 데스크톱 여유 폭을 더 보수적으로 잡아, `1920px` 근처에서 우측 페이지가 잘려 가로 스크롤이 생기던 문제를 줄이는 방향으로 조정했다.
- 달력 날짜 버튼은 셀 안쪽에 고정 크기 원형 버튼으로 다시 배치해서 모바일에서 서로 겹쳐 보이는 현상을 줄였다.
- 좌측 상단 소개 영역은 개발 설명 문장을 빼고, 서비스 소개용 짧은 카피와 배지 스타일로 다시 구성했다.
- 달력 날짜 버튼은 의도상 원형이며, 현재는 `size` 고정 기준으로 다시 맞춰서 타원처럼 보이는 인상을 줄이는 방향으로 정리했다.
- 플래너 본문, 2페이지 펼침, 인쇄 전용 `PlannerPage` 모두 `총 시간` 값은 `00시간 00분` 한글 포맷으로 통일했다.
- 모바일 대응 이후 인쇄에서 `TIME TABLE`이 사라지던 문제를 막기 위해, print 시에는 `PlannerPage` 내부 레이아웃을 다시 가로 배치로 고정하고 타임테이블 오버플로를 해제하도록 보정했다.
- 인쇄 레이아웃은 추가로 미세 조정해 `COMMENT` 영역이 잘리지 않도록 textarea 높이/행간을 print 전용으로 풀고, `1-UP` / `2-UP` 배율도 프레임 실측 기준으로 다시 계산했다.
- `TODO.md`는 중복 체크 항목을 정리했고, 인증 확장을 위해 `이메일 인증 / 비밀번호 재설정 / rate limit / 메일 인프라` 작업을 별도 항목으로 추가했다.
- Resend 무료 플랜은 도메인 1개 제약이 있어 현재 프로젝트 인증 메일에는 바로 쓰기 어렵다. 다음 단계에서는 AWS SES 또는 범용 SMTP 공급자 기준으로 메일 발송 추상화를 붙이는 쪽이 적합하다.
- 현재 인증 메일/재설정 메일은 실제 발송 대신 개발용 `previewUrl`을 응답으로 돌려주는 단계다. 프론트 UI 연결과 실제 메일러 연결은 다음 단계에서 마무리하면 된다.
- 미니 달력 날짜 버튼은 원형 비율이 흔들리지 않도록 고정 `width/height` 기준으로 다시 맞췄다.
- 플래너 본문 D-DAY 텍스트는 3줄까지만 보이고, 넘치면 말줄임 처리되도록 정리했다.
- 목표가 없는 빈 날짜에서는 `D-DAY 사용` 토글이 저장 상태와 무관하게 `OFF + 비활성`처럼 보이도록 보정했다.
- 관리자 전용 `ADMIN` 메뉴와 기본 대시보드가 추가되었다. 현재는 사용자 수, 최근 접속, 문서 수, 목표 수, 계정별 최종 접속일을 읽기 전용으로 확인할 수 있다.
- `users` 테이블에 `role`, `last_login_at` 컬럼이 추가되었다.
- 관리자 이메일은 현재 `ADMIN_EMAILS` 환경변수로 판별한다. 기본값은 `zenn.message@gmail.com`이며, 쉼표로 여러 이메일을 넣을 수 있다.
- 관리자 대시보드는 현재 읽기 전용이며, 계정 정지/삭제/강제 로그아웃 같은 실제 운영 액션은 아직 없다.
- 배포용 `docker-compose.yml`은 현재 PostgreSQL 외부 포트 `45432`, 프론트 외부 포트 `48081`, DB 계정 `zenn` 기준으로 맞춰져 있다.
- 설정 화면의 보조 메모 카드는 주석 처리되어 현재는 보이지 않는다.
- 비로그인 랜딩 카드는 상단 고정이 아니라 화면 중앙에 오도록 정렬을 수정했다. - 비로그인 랜딩 카드는 상단 고정이 아니라 화면 중앙에 오도록 정렬을 수정했다.
- 현재 환경에서는 Docker 데몬이 꺼져 있어서 `docker compose build` 실검증은 하지 못했고, 데몬 시작 후 다시 확인이 필요하다. - 현재 환경에서는 Docker 데몬이 꺼져 있어서 `docker compose build` 실검증은 하지 못했고, 데몬 시작 후 다시 확인이 필요하다.
- 이미지 저장 기능은 추후 `print-only` 또는 별도 export 전용 레이아웃을 기준으로 구현하면 화면/인쇄/공유 결과를 맞추기 쉽다. - 이미지 저장 기능은 추후 `print-only` 또는 별도 export 전용 레이아웃을 기준으로 구현하면 화면/인쇄/공유 결과를 맞추기 쉽다.

126
README.md
View File

@@ -2,6 +2,119 @@
Vue 3 + TailwindCSS + Fastify + PostgreSQL 기반의 `10분 플래너 다이어리` 프로젝트다. Vue 3 + TailwindCSS + Fastify + PostgreSQL 기반의 `10분 플래너 다이어리` 프로젝트다.
## 시작 전에
이 프로젝트는 `Docker``Docker Compose`가 설치된 환경을 기준으로 실행한다.
NAS나 서버에서 처음 올리는 경우 흐름은 아래처럼 생각하면 된다.
1. 프로젝트를 받을 폴더로 이동한다.
2. `git clone`으로 저장소를 내려받는다.
3. 내려받은 프로젝트 폴더로 들어간다.
4. `docker compose`로 컨테이너를 빌드하고 실행한다.
예시:
```bash
cd /volume1/docker
git clone https://git.sori.studio/zenn/planner.sori.studio.git
cd planner.sori.studio
docker compose up -d --build
```
처음 한 번은 이미지 빌드 때문에 시간이 걸릴 수 있다.
## 초보자용 빠른 실행
### 1. 프로젝트 받기
원하는 작업 폴더로 이동한 뒤 저장소를 내려받는다.
```bash
cd /원하는/폴더
git clone https://git.sori.studio/zenn/planner.sori.studio.git
cd planner.sori.studio
```
### 2. 배포용으로 바로 실행하기
실제 동작 확인이나 NAS 상시 실행은 아래 명령으로 시작한다.
```bash
docker compose up -d --build
```
의미:
- `up`: 컨테이너를 실행한다.
- `-d`: 백그라운드에서 실행한다.
- `--build`: 이미지가 없거나 코드가 바뀌었을 때 다시 빌드한다.
브라우저 접속 주소:
- 프론트엔드: `http://NAS주소:48081`
- PostgreSQL: `NAS주소:45432`
현재 `docker-compose.yml` 기준 내부 구성:
- 프론트엔드 nginx
- 백엔드 Fastify
- PostgreSQL
### 3. 실행 상태 확인하기
```bash
docker compose ps
```
로그를 보고 싶으면:
```bash
docker compose logs -f
```
특정 서비스만 보고 싶으면:
```bash
docker compose logs -f frontend
docker compose logs -f backend
docker compose logs -f postgres
```
### 4. 종료하기
```bash
docker compose down
```
데이터베이스 볼륨까지 완전히 지우고 처음부터 다시 시작하고 싶을 때만 아래 명령을 사용한다.
```bash
docker compose down -v
```
주의:
- `-v`는 PostgreSQL 데이터까지 지울 수 있으니 정말 초기화가 필요할 때만 사용한다.
### 5. 코드 수정 후 다시 반영하기
배포용 compose는 코드가 자동 반영되지 않는다.
코드를 수정했다면 프로젝트 폴더 안에서 다시 실행한다.
```bash
docker compose up -d --build
```
즉, NAS에서 배포용으로 돌릴 때는 보통 아래 순서를 반복한다.
```bash
cd /volume1/docker/planner.sori.studio
git pull
docker compose up -d --build
```
## 실행 방법 ## 실행 방법
### 개발용 ### 개발용
@@ -12,6 +125,15 @@ Vue 3 + TailwindCSS + Fastify + PostgreSQL 기반의 `10분 플래너 다이어
docker compose -f docker-compose.dev.yml up docker compose -f docker-compose.dev.yml up
``` ```
개발용도 처음 시작할 때는 아래처럼 프로젝트 폴더 안에서 실행하면 된다.
```bash
cd /원하는/폴더
git clone https://git.sori.studio/zenn/planner.sori.studio.git
cd planner.sori.studio
docker compose -f docker-compose.dev.yml up
```
개발용 포트: 개발용 포트:
- 프론트엔드: `http://localhost:5173` - 프론트엔드: `http://localhost:5173`
@@ -34,8 +156,8 @@ docker compose up -d --build
배포용 포트: 배포용 포트:
- 프론트엔드: `http://localhost:8080` - 프론트엔드: `http://localhost:48081`
- PostgreSQL: `localhost:5432` - PostgreSQL: `localhost:45432`
배포용 특징: 배포용 특징:

15
TODO.md
View File

@@ -46,7 +46,7 @@
- [x] 목표별로 D-DAY 표시 시작일과 종료일을 설정할 수 있게 한다. - [x] 목표별로 D-DAY 표시 시작일과 종료일을 설정할 수 있게 한다.
- [x] 목표 표시 기간이 서로 겹치면 저장되지 않도록 막는다. - [x] 목표 표시 기간이 서로 겹치면 저장되지 않도록 막는다.
- [ ] 목표 완료 처리와 보관 상태를 구분한다. - [ ] 목표 완료 처리와 보관 상태를 구분한다.
- [ ] 목표 편집/삭제 UI를 추가한다. - [x] 목표 편집/삭제 UI를 추가한다.
- [ ] 목표 목록 정렬 규칙과 검색 UX를 다듬는다. - [ ] 목표 목록 정렬 규칙과 검색 UX를 다듬는다.
## 4단계: 데이터 구조와 저장 ## 4단계: 데이터 구조와 저장
@@ -56,9 +56,7 @@
- [x] 로컬 저장 또는 외부 저장 방식 중 우선 구현 방식을 정한다. - [x] 로컬 저장 또는 외부 저장 방식 중 우선 구현 방식을 정한다.
- [x] 입력 상태가 새로고침 후에도 유지되도록 만든다. - [x] 입력 상태가 새로고침 후에도 유지되도록 만든다.
- [x] DB 전환 시점을 잡을 수 있도록 저장 레이어를 분리한다. - [x] DB 전환 시점을 잡을 수 있도록 저장 레이어를 분리한다.
- [ ] 회원 가입 및 로그인 구조를 고려한 사용자별 데이터 모델을 설계한다.
- [x] 회원 가입 및 로그인 구조를 고려한 사용자별 데이터 모델을 설계한다. - [x] 회원 가입 및 로그인 구조를 고려한 사용자별 데이터 모델을 설계한다.
- [ ] 사용자별 문서 저장/조회 흐름을 정리한다.
- [x] 사용자별 문서 저장/조회 흐름을 정리한다. - [x] 사용자별 문서 저장/조회 흐름을 정리한다.
- [ ] 출력용 문서 포맷과 프린트 흐름을 고려한 데이터 구조를 정리한다. - [ ] 출력용 문서 포맷과 프린트 흐름을 고려한 데이터 구조를 정리한다.
@@ -77,7 +75,6 @@
## 6단계: 계정 및 서비스 확장 ## 6단계: 계정 및 서비스 확장
- [ ] 회원 가입 / 로그인 방식 후보를 정리한다.
- [x] 회원 가입 / 로그인 방식 후보를 정리한다. - [x] 회원 가입 / 로그인 방식 후보를 정리한다.
- [x] 사용자 설정 화면에서 닉네임 / 이메일 / 비밀번호 수정 흐름을 분리한다. - [x] 사용자 설정 화면에서 닉네임 / 이메일 / 비밀번호 수정 흐름을 분리한다.
- [x] 상단 헤더를 왼쪽 사이드 내비게이션 구조로 재배치한다. - [x] 상단 헤더를 왼쪽 사이드 내비게이션 구조로 재배치한다.
@@ -92,6 +89,14 @@
- [x] UGREEN NAS 기준 `docker-compose.yml` 초안을 작성한다. - [x] UGREEN NAS 기준 `docker-compose.yml` 초안을 작성한다.
- [x] 백엔드 기본 스캐폴딩을 추가한다. - [x] 백엔드 기본 스캐폴딩을 추가한다.
- [x] PostgreSQL 전환 초안을 적용한다. - [x] PostgreSQL 전환 초안을 적용한다.
- [ ] 이메일 인증 플로우를 설계하고 구현한다.
- [ ] 비밀번호 찾기 / 재설정 토큰 흐름을 추가한다.
- [ ] 로그인 및 인증 관련 rate limit / 잠금 정책을 추가한다.
- [ ] 메일 발송 인프라와 발신 도메인 정책을 확정한다.
- [ ] Resend 무료 플랜 대체 수단으로 SES 또는 일반 SMTP 연동 방식을 확정한다.
- [ ] 관리자 페이지에서 계정 비활성화 / 강제 로그아웃 / 삭제 기능을 추가한다.
- [ ] 관리자 페이지에서 사용자별 문서 상세 조회 기능을 추가한다.
- [ ] 관리자 페이지에서 검색 / 정렬 / 필터 UX를 추가한다.
## 메모 ## 메모
@@ -122,3 +127,5 @@
- 표시 기간이 다른 진행 중 목표와 겹치면 프론트와 백엔드 모두 저장을 막는다. - 표시 기간이 다른 진행 중 목표와 겹치면 프론트와 백엔드 모두 저장을 막는다.
- TASK LABELS는 버튼 묶음 대신 동일한 토글 패턴으로 단순화했다. - TASK LABELS는 버튼 묶음 대신 동일한 토글 패턴으로 단순화했다.
- 구현할 때마다 완료된 항목은 체크하고, 큰 결정사항은 `HANDOFF.md`에도 함께 반영한다. - 구현할 때마다 완료된 항목은 체크하고, 큰 결정사항은 `HANDOFF.md`에도 함께 반영한다.
- Resend 무료 플랜은 도메인 수 제약이 있으므로, 실제 인증 메일은 AWS SES 또는 별도 SMTP 서비스 전환을 전제로 설계하는 편이 안전하다.
- 관리자 기능은 읽기 전용 대시보드부터 시작하고, 실제 정리 액션은 권한/감사 로그 정책을 정한 뒤 추가하는 편이 안전하다.

View File

@@ -13,6 +13,8 @@ const envSchema = z.object({
DATABASE_URL: z.string().min(1).default('postgresql://planner:planner1234@localhost:5432/ten_minute_planner'), DATABASE_URL: z.string().min(1).default('postgresql://planner:planner1234@localhost:5432/ten_minute_planner'),
CORS_ORIGIN: z.string().default('http://localhost:5173'), CORS_ORIGIN: z.string().default('http://localhost:5173'),
SESSION_TTL_DAYS: z.coerce.number().default(30), SESSION_TTL_DAYS: z.coerce.number().default(30),
APP_BASE_URL: z.string().default('http://localhost:5173'),
ADMIN_EMAILS: z.string().default('zenn.message@gmail.com'),
}) })
export const env = envSchema.parse(process.env) export const env = envSchema.parse(process.env)

View File

@@ -7,10 +7,22 @@ export async function ensureDatabaseSchema() {
email VARCHAR(255) NOT NULL UNIQUE, email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL, password_hash VARCHAR(255) NOT NULL,
nickname VARCHAR(60) NOT NULL, nickname VARCHAR(60) NOT NULL,
role VARCHAR(20) NOT NULL DEFAULT 'user',
email_verified_at TIMESTAMPTZ,
last_login_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL updated_at TIMESTAMPTZ NOT NULL
); );
ALTER TABLE users
ADD COLUMN IF NOT EXISTS role VARCHAR(20) NOT NULL DEFAULT 'user';
ALTER TABLE users
ADD COLUMN IF NOT EXISTS email_verified_at TIMESTAMPTZ;
ALTER TABLE users
ADD COLUMN IF NOT EXISTS last_login_at TIMESTAMPTZ;
CREATE TABLE IF NOT EXISTS auth_sessions ( CREATE TABLE IF NOT EXISTS auth_sessions (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@@ -51,5 +63,29 @@ export async function ensureDatabaseSchema() {
CREATE INDEX IF NOT EXISTS goals_user_id_idx CREATE INDEX IF NOT EXISTS goals_user_id_idx
ON goals (user_id); ON goals (user_id);
CREATE TABLE IF NOT EXISTS email_verification_tokens (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash VARCHAR(255) NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS email_verification_tokens_user_id_idx
ON email_verification_tokens (user_id);
CREATE TABLE IF NOT EXISTS password_reset_tokens (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash VARCHAR(255) NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS password_reset_tokens_user_id_idx
ON password_reset_tokens (user_id);
`) `)
} }

View File

@@ -14,6 +14,9 @@ export const users = pgTable('users', {
email: varchar('email', { length: 255 }).notNull().unique(), email: varchar('email', { length: 255 }).notNull().unique(),
passwordHash: varchar('password_hash', { length: 255 }).notNull(), passwordHash: varchar('password_hash', { length: 255 }).notNull(),
nickname: varchar('nickname', { length: 60 }).notNull(), nickname: varchar('nickname', { length: 60 }).notNull(),
role: varchar('role', { length: 20 }).notNull().default('user'),
emailVerifiedAt: timestamp('email_verified_at', { withTimezone: true }),
lastLoginAt: timestamp('last_login_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull(), createdAt: timestamp('created_at', { withTimezone: true }).notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull(), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull(),
}) })
@@ -65,3 +68,33 @@ export const goals = pgTable(
userIndex: index('goals_user_id_idx').on(table.userId), userIndex: index('goals_user_id_idx').on(table.userId),
}), }),
) )
export const emailVerificationTokens = pgTable(
'email_verification_tokens',
{
id: serial('id').primaryKey(),
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
tokenHash: varchar('token_hash', { length: 255 }).notNull().unique(),
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
usedAt: timestamp('used_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull(),
},
(table) => ({
userIndex: index('email_verification_tokens_user_id_idx').on(table.userId),
}),
)
export const passwordResetTokens = pgTable(
'password_reset_tokens',
{
id: serial('id').primaryKey(),
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
tokenHash: varchar('token_hash', { length: 255 }).notNull().unique(),
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
usedAt: timestamp('used_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull(),
},
(table) => ({
userIndex: index('password_reset_tokens_user_id_idx').on(table.userId),
}),
)

103
backend/src/routes/admin.js Normal file
View File

@@ -0,0 +1,103 @@
import { sql } from 'drizzle-orm'
import { db } from '../db/client.js'
import { findAuthenticatedUser } from '../lib/authSession.js'
import { users } from '../db/schema.js'
async function requireAdminUser(request, reply) {
const user = await findAuthenticatedUser(request)
if (!user) {
reply.code(401).send({
message: '인증이 필요합니다.',
})
return null
}
if (user.role !== 'admin') {
reply.code(403).send({
message: '관리자만 접근할 수 있습니다.',
})
return null
}
return user
}
export async function registerAdminRoutes(app) {
app.get('/api/admin/overview', async (request, reply) => {
const adminUser = await requireAdminUser(request, reply)
if (!adminUser) {
return
}
const [summary] = await db
.select({
totalUsers: sql<number>`count(*)::int`,
totalAdmins: sql<number>`count(*) filter (where ${users.role} = 'admin')::int`,
verifiedUsers: sql<number>`count(*) filter (where ${users.emailVerifiedAt} is not null)::int`,
activeUsers30d: sql<number>`count(*) filter (where ${users.lastLoginAt} >= now() - interval '30 days')::int`,
newUsers7d: sql<number>`count(*) filter (where ${users.createdAt} >= now() - interval '7 days')::int`,
})
.from(users)
const plannerCountResult = await db.execute(
sql`select count(*)::int as count from planner_entries`,
)
const goalCountResult = await db.execute(
sql`select count(*)::int as count from goals`,
)
const userRowsResult = await db.execute(sql`
select
u.id,
u.nickname,
u.email,
u.role,
u.created_at as "createdAt",
u.email_verified_at as "emailVerifiedAt",
u.last_login_at as "lastLoginAt",
count(distinct pe.id)::int as "plannerEntryCount",
count(distinct g.id)::int as "goalCount",
max(pe.entry_date) as "lastEntryDate",
max(pe.updated_at) as "lastEntryUpdatedAt"
from users u
left join planner_entries pe on pe.user_id = u.id
left join goals g on g.user_id = u.id
group by u.id
order by coalesce(u.last_login_at, u.created_at) desc, u.id desc
`)
const recentLoginsResult = await db.execute(sql`
select
id,
nickname,
email,
role,
last_login_at as "lastLoginAt"
from users
where last_login_at is not null
order by last_login_at desc
limit 5
`)
return {
summary: {
totalUsers: summary?.totalUsers ?? 0,
totalAdmins: summary?.totalAdmins ?? 0,
verifiedUsers: summary?.verifiedUsers ?? 0,
activeUsers30d: summary?.activeUsers30d ?? 0,
newUsers7d: summary?.newUsers7d ?? 0,
totalPlannerEntries: plannerCountResult.rows[0]?.count ?? 0,
totalGoals: goalCountResult.rows[0]?.count ?? 0,
},
users: userRowsResult.rows.map((row) => ({
...row,
isActiveRecently: row.lastLoginAt
? new Date(row.lastLoginAt).getTime() >= Date.now() - 30 * 24 * 60 * 60 * 1000
: false,
})),
recentLogins: recentLoginsResult.rows,
}
})
}

View File

@@ -1,8 +1,9 @@
import { eq } from 'drizzle-orm' import { and, eq, gt, isNull } from 'drizzle-orm'
import { env } from '../config.js'
import { z } from 'zod' import { z } from 'zod'
import { db } from '../db/client.js' import { db } from '../db/client.js'
import { users } from '../db/schema.js' import { authSessions, emailVerificationTokens, passwordResetTokens, users } from '../db/schema.js'
import { hashPassword, verifyPassword } from '../lib/password.js' import { createSessionToken, hashSessionToken, hashPassword, verifyPassword } from '../lib/password.js'
import { createSession, findAuthenticatedUser } from '../lib/authSession.js' import { createSession, findAuthenticatedUser } from '../lib/authSession.js'
const signupSchema = z.object({ const signupSchema = z.object({
@@ -26,11 +27,90 @@ const passwordSchema = z.object({
newPassword: z.string().min(8).max(72), newPassword: z.string().min(8).max(72),
}) })
const verificationRequestSchema = z.object({
email: z.string().trim().email().optional(),
})
const verificationConfirmSchema = z.object({
token: z.string().trim().min(20).max(255),
})
const passwordResetRequestSchema = z.object({
email: z.string().trim().email(),
})
const passwordResetConfirmSchema = z.object({
token: z.string().trim().min(20).max(255),
newPassword: z.string().min(8).max(72),
})
const TOKEN_TTL_MS = 1000 * 60 * 30
const adminEmails = new Set(
env.ADMIN_EMAILS.split(',')
.map((email) => email.trim().toLowerCase())
.filter(Boolean),
)
function resolveUserRole(email) {
return adminEmails.has(email.toLowerCase()) ? 'admin' : 'user'
}
function buildPreviewUrl(pathname, token) {
const url = new URL(pathname, env.APP_BASE_URL)
url.searchParams.set('token', token)
return url.toString()
}
async function createEmailVerificationToken(userId) {
const token = createSessionToken()
const tokenHash = hashSessionToken(token)
const now = new Date()
const expiresAt = new Date(now.getTime() + TOKEN_TTL_MS)
await db.delete(emailVerificationTokens).where(eq(emailVerificationTokens.userId, userId))
await db.insert(emailVerificationTokens).values({
userId,
tokenHash,
expiresAt,
createdAt: now,
})
return {
token,
previewUrl: buildPreviewUrl('/verify-email', token),
}
}
async function createPasswordResetToken(userId) {
const token = createSessionToken()
const tokenHash = hashSessionToken(token)
const now = new Date()
const expiresAt = new Date(now.getTime() + TOKEN_TTL_MS)
await db.delete(passwordResetTokens).where(eq(passwordResetTokens.userId, userId))
await db.insert(passwordResetTokens).values({
userId,
tokenHash,
expiresAt,
createdAt: now,
})
return {
token,
previewUrl: buildPreviewUrl('/reset-password', token),
}
}
function sanitizeUser(user) { function sanitizeUser(user) {
return { return {
id: user.id, id: user.id,
email: user.email, email: user.email,
nickname: user.nickname, nickname: user.nickname,
role: user.role,
emailVerifiedAt: user.emailVerifiedAt,
lastLoginAt: user.lastLoginAt,
createdAt: user.createdAt, createdAt: user.createdAt,
updatedAt: user.updatedAt, updatedAt: user.updatedAt,
} }
@@ -64,6 +144,7 @@ export async function registerAuthRoutes(app) {
const now = new Date() const now = new Date()
const passwordHash = await hashPassword(password) const passwordHash = await hashPassword(password)
const role = resolveUserRole(normalizedEmail)
const [user] = await db const [user] = await db
.insert(users) .insert(users)
@@ -71,17 +152,22 @@ export async function registerAuthRoutes(app) {
email: normalizedEmail, email: normalizedEmail,
passwordHash, passwordHash,
nickname, nickname,
role,
emailVerifiedAt: null,
lastLoginAt: now,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}) })
.returning() .returning()
const { token } = await createSession(user.id) const { token } = await createSession(user.id)
const verification = await createEmailVerificationToken(user.id)
return reply.code(201).send({ return reply.code(201).send({
message: '회원가입이 완료되었습니다.', message: '회원가입이 완료되었습니다.',
token, token,
user: sanitizeUser(user), user: sanitizeUser(user),
verificationPreviewUrl: verification.previewUrl,
}) })
}) })
@@ -117,12 +203,25 @@ export async function registerAuthRoutes(app) {
}) })
} }
const now = new Date()
const role = resolveUserRole(user.email)
const [updatedUser] = await db
.update(users)
.set({
role,
lastLoginAt: now,
updatedAt: now,
})
.where(eq(users.id, user.id))
.returning()
const { token } = await createSession(user.id) const { token } = await createSession(user.id)
return { return {
message: '로그인에 성공했습니다.', message: '로그인에 성공했습니다.',
token, token,
user: sanitizeUser(user), user: sanitizeUser(updatedUser),
} }
}) })
@@ -135,6 +234,23 @@ export async function registerAuthRoutes(app) {
}) })
} }
const resolvedRole = resolveUserRole(user.email)
if (user.role !== resolvedRole) {
const [updatedUser] = await db
.update(users)
.set({
role: resolvedRole,
updatedAt: new Date(),
})
.where(eq(users.id, user.id))
.returning()
return {
user: sanitizeUser(updatedUser),
}
}
return { return {
user: sanitizeUser(user), user: sanitizeUser(user),
} }
@@ -177,6 +293,7 @@ export async function registerAuthRoutes(app) {
.set({ .set({
email: normalizedEmail, email: normalizedEmail,
nickname: payload.data.nickname, nickname: payload.data.nickname,
role: resolveUserRole(normalizedEmail),
updatedAt: new Date(), updatedAt: new Date(),
}) })
.where(eq(users.id, user.id)) .where(eq(users.id, user.id))
@@ -228,4 +345,188 @@ export async function registerAuthRoutes(app) {
message: '비밀번호가 변경되었습니다.', message: '비밀번호가 변경되었습니다.',
} }
}) })
app.post('/api/auth/verification/request', async (request, reply) => {
const authenticatedUser = await findAuthenticatedUser(request)
const payload = verificationRequestSchema.safeParse(request.body ?? {})
if (!payload.success) {
return reply.code(400).send({
message: '이메일 입력값이 올바르지 않습니다.',
issues: payload.error.flatten(),
})
}
const normalizedEmail = (payload.data.email || authenticatedUser?.email || '').toLowerCase()
if (!normalizedEmail) {
return reply.code(400).send({
message: '인증 메일을 받을 이메일이 필요합니다.',
})
}
const [user] = await db
.select()
.from(users)
.where(eq(users.email, normalizedEmail))
.limit(1)
if (!user) {
return {
message: '입력한 이메일로 인증 안내를 보낼 준비가 되면 처리됩니다.',
}
}
if (user.emailVerifiedAt) {
return {
message: '이미 이메일 인증이 완료된 계정입니다.',
}
}
const verification = await createEmailVerificationToken(user.id)
return {
message: '이메일 인증 링크를 준비했습니다.',
verificationPreviewUrl: verification.previewUrl,
}
})
app.post('/api/auth/verification/confirm', async (request, reply) => {
const payload = verificationConfirmSchema.safeParse(request.body)
if (!payload.success) {
return reply.code(400).send({
message: '인증 토큰이 올바르지 않습니다.',
issues: payload.error.flatten(),
})
}
const tokenHash = hashSessionToken(payload.data.token)
const [verification] = await db
.select()
.from(emailVerificationTokens)
.where(
and(
eq(emailVerificationTokens.tokenHash, tokenHash),
isNull(emailVerificationTokens.usedAt),
gt(emailVerificationTokens.expiresAt, new Date()),
),
)
.limit(1)
if (!verification) {
return reply.code(400).send({
message: '이미 사용했거나 만료된 인증 링크입니다.',
})
}
const now = new Date()
await db
.update(users)
.set({
emailVerifiedAt: now,
updatedAt: now,
})
.where(eq(users.id, verification.userId))
await db
.update(emailVerificationTokens)
.set({
usedAt: now,
})
.where(eq(emailVerificationTokens.id, verification.id))
return {
message: '이메일 인증이 완료되었습니다.',
}
})
app.post('/api/auth/password-reset/request', async (request, reply) => {
const payload = passwordResetRequestSchema.safeParse(request.body)
if (!payload.success) {
return reply.code(400).send({
message: '이메일 입력값이 올바르지 않습니다.',
issues: payload.error.flatten(),
})
}
const normalizedEmail = payload.data.email.toLowerCase()
const [user] = await db
.select()
.from(users)
.where(eq(users.email, normalizedEmail))
.limit(1)
if (!user) {
return {
message: '입력한 이메일로 재설정 안내를 보낼 준비가 되면 처리됩니다.',
}
}
const reset = await createPasswordResetToken(user.id)
return {
message: '비밀번호 재설정 링크를 준비했습니다.',
resetPreviewUrl: reset.previewUrl,
}
})
app.post('/api/auth/password-reset/confirm', async (request, reply) => {
const payload = passwordResetConfirmSchema.safeParse(request.body)
if (!payload.success) {
return reply.code(400).send({
message: '비밀번호 재설정 입력값이 올바르지 않습니다.',
issues: payload.error.flatten(),
})
}
const tokenHash = hashSessionToken(payload.data.token)
const [resetToken] = await db
.select()
.from(passwordResetTokens)
.where(
and(
eq(passwordResetTokens.tokenHash, tokenHash),
isNull(passwordResetTokens.usedAt),
gt(passwordResetTokens.expiresAt, new Date()),
),
)
.limit(1)
if (!resetToken) {
return reply.code(400).send({
message: '이미 사용했거나 만료된 재설정 링크입니다.',
})
}
const now = new Date()
const passwordHash = await hashPassword(payload.data.newPassword)
await db
.update(users)
.set({
passwordHash,
updatedAt: now,
})
.where(eq(users.id, resetToken.userId))
await db
.update(passwordResetTokens)
.set({
usedAt: now,
})
.where(eq(passwordResetTokens.id, resetToken.id))
await db.delete(authSessions).where(eq(authSessions.userId, resetToken.userId))
return {
message: '비밀번호가 재설정되었습니다. 다시 로그인해 주세요.',
}
})
} }

View File

@@ -4,6 +4,7 @@ import { env } from './config.js'
import { pool } from './db/client.js' import { pool } from './db/client.js'
import { ensureDatabaseSchema } from './db/init.js' import { ensureDatabaseSchema } from './db/init.js'
import { registerAuthRoutes } from './routes/auth.js' import { registerAuthRoutes } from './routes/auth.js'
import { registerAdminRoutes } from './routes/admin.js'
import { registerGoalRoutes } from './routes/goals.js' import { registerGoalRoutes } from './routes/goals.js'
import { registerPlannerRoutes } from './routes/planner.js' import { registerPlannerRoutes } from './routes/planner.js'
@@ -19,6 +20,7 @@ await app.register(cors, {
}) })
await registerAuthRoutes(app) await registerAuthRoutes(app)
await registerAdminRoutes(app)
await registerGoalRoutes(app) await registerGoalRoutes(app)
await registerPlannerRoutes(app) await registerPlannerRoutes(app)
@@ -42,6 +44,7 @@ app.get('/api/meta', async () => ({
orm: 'drizzle', orm: 'drizzle',
notes: [ notes: [
'회원가입, 로그인, 현재 사용자 확인 API가 준비되어 있습니다.', '회원가입, 로그인, 현재 사용자 확인 API가 준비되어 있습니다.',
'관리자용 사용자 현황 요약 API가 준비되어 있습니다.',
'사용자별 목표 목록, 수정, 삭제 API가 준비되어 있습니다.', '사용자별 목표 목록, 수정, 삭제 API가 준비되어 있습니다.',
'사용자별 플래너 저장 및 조회 API가 준비되어 있습니다.', '사용자별 플래너 저장 및 조회 API가 준비되어 있습니다.',
], ],

View File

@@ -4,14 +4,14 @@ services:
container_name: ten-minute-postgres container_name: ten-minute-postgres
environment: environment:
POSTGRES_DB: ten_minute_planner POSTGRES_DB: ten_minute_planner
POSTGRES_USER: planner POSTGRES_USER: zenn
POSTGRES_PASSWORD: planner1234 POSTGRES_PASSWORD: wps!vmffosj180204
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
ports: ports:
- "5432:5432" - "45432:5432"
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U planner -d ten_minute_planner"] test: ["CMD-SHELL", "pg_isready -U zenn -d ten_minute_planner"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 10 retries: 10
@@ -23,8 +23,8 @@ services:
container_name: ten-minute-backend container_name: ten-minute-backend
environment: environment:
PORT: 3001 PORT: 3001
DATABASE_URL: postgresql://planner:planner1234@postgres:5432/ten_minute_planner DATABASE_URL: postgresql://zenn:wps%21vmffosj180204@postgres:5432/ten_minute_planner
CORS_ORIGIN: http://localhost:8080 CORS_ORIGIN: http://localhost:48081
SESSION_TTL_DAYS: 30 SESSION_TTL_DAYS: 30
depends_on: depends_on:
postgres: postgres:
@@ -42,8 +42,8 @@ services:
depends_on: depends_on:
- backend - backend
ports: ports:
- "8080:80" - "48081:80"
restart: unless-stopped restart: unless-stopped
volumes: volumes:
postgres_data: postgres_data:

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "ten-minute-planner", "name": "ten-minute-planner",
"version": "0.1.33", "version": "0.1.41",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ten-minute-planner", "name": "ten-minute-planner",
"version": "0.1.33", "version": "0.1.41",
"dependencies": { "dependencies": {
"vue": "^3.5.13" "vue": "^3.5.13"
}, },

View File

@@ -1,7 +1,7 @@
{ {
"name": "ten-minute-planner", "name": "ten-minute-planner",
"private": true, "private": true,
"version": "0.1.33", "version": "0.1.41",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@@ -1,11 +1,13 @@
<script setup> <script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue' import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import AdminDashboard from './components/AdminDashboard.vue'
import AuthDialog from './components/AuthDialog.vue' import AuthDialog from './components/AuthDialog.vue'
import GoalsDashboard from './components/GoalsDashboard.vue' import GoalsDashboard from './components/GoalsDashboard.vue'
import MiniCalendar from './components/MiniCalendar.vue' import MiniCalendar from './components/MiniCalendar.vue'
import PlannerPage from './components/PlannerPage.vue' import PlannerPage from './components/PlannerPage.vue'
import SettingsDashboard from './components/SettingsDashboard.vue' import SettingsDashboard from './components/SettingsDashboard.vue'
import StatsDashboard from './components/StatsDashboard.vue' import StatsDashboard from './components/StatsDashboard.vue'
import { fetchAdminOverview } from './lib/adminApi'
import { import {
clearAuthState, clearAuthState,
fetchCurrentUser, fetchCurrentUser,
@@ -74,6 +76,19 @@ const profileBusy = ref(false)
const passwordBusy = ref(false) const passwordBusy = ref(false)
const profileMessage = ref('') const profileMessage = ref('')
const passwordMessage = ref('') const passwordMessage = ref('')
const adminBusy = ref(false)
const adminMessage = ref('')
const adminOverview = ref({
totalUsers: 0,
totalAdmins: 0,
verifiedUsers: 0,
activeUsers30d: 0,
newUsers7d: 0,
totalPlannerEntries: 0,
totalGoals: 0,
})
const adminUsers = ref([])
const adminRecentLogins = ref([])
const hours = [ const hours = [
'6', '7', '8', '9', '10', '11', '12', '6', '7', '8', '9', '10', '11', '12',
@@ -212,7 +227,7 @@ function startOfDay(date) {
function buildFallbackRecord(date) { function buildFallbackRecord(date) {
return { return {
comment: '', comment: '',
goalEnabled: true, goalEnabled: false,
selectedGoalId: null, selectedGoalId: null,
tasks: Array.from({ length: 15 }, (_, index) => ({ tasks: Array.from({ length: 15 }, (_, index) => ({
label: '', label: '',
@@ -336,6 +351,7 @@ const markedDateKeys = computed(() =>
) )
const isAuthenticated = computed(() => Boolean(authToken.value && currentUser.value)) const isAuthenticated = computed(() => Boolean(authToken.value && currentUser.value))
const isAdmin = computed(() => currentUser.value?.role === 'admin')
const filteredGoals = computed(() => { const filteredGoals = computed(() => {
const query = goalQuery.value.trim().toLowerCase() const query = goalQuery.value.trim().toLowerCase()
return goals.value.filter((goal) => { return goals.value.filter((goal) => {
@@ -363,6 +379,7 @@ const activePlannerGoals = computed(() =>
}), }),
) )
const plannerGoal = computed(() => activePlannerGoals.value[0] ?? null) const plannerGoal = computed(() => activePlannerGoals.value[0] ?? null)
const plannerGoalToggleOn = computed(() => Boolean(plannerGoal.value) && planner.value.goalEnabled)
const plannerDday = computed(() => { const plannerDday = computed(() => {
if (!planner.value.goalEnabled || !plannerGoal.value) { if (!planner.value.goalEnabled || !plannerGoal.value) {
return '' return ''
@@ -700,6 +717,20 @@ const readNextItems = computed(() => {
] ]
}) })
const nextDaySummary = computed(() => {
const nextDayFirstTask = secondaryPlanner.value.tasks.find((task) => task.title.trim())
if (!hasPlannerContent(secondaryPlanner.value)) {
return '등록된 일정이 없습니다.'
}
if (!nextDayFirstTask) {
return '등록된 일정이 없습니다.'
}
return `내일의 첫 작업은 "${nextDayFirstTask.title}" 입니다.`
})
const isWideFocusSidebar = computed(() => windowWidth.value >= 1620) const isWideFocusSidebar = computed(() => windowWidth.value >= 1620)
const isOverlayFocusSidebar = computed(() => windowWidth.value < 1280) const isOverlayFocusSidebar = computed(() => windowWidth.value < 1280)
const showInlineLeftSidebar = computed(() => windowWidth.value >= 1280) const showInlineLeftSidebar = computed(() => windowWidth.value >= 1280)
@@ -718,7 +749,7 @@ const focusSidebarGridClass = computed(() =>
: 'grid-cols-1', : 'grid-cols-1',
) )
const spreadScale = computed(() => { const spreadScale = computed(() => {
const reservedWidth = windowWidth.value >= 1280 ? 410 : 96 const reservedWidth = windowWidth.value >= 1280 ? 532 : 120
const availableWidth = Math.max(windowWidth.value - reservedWidth, 980) const availableWidth = Math.max(windowWidth.value - reservedWidth, 980)
const baseWidth = 1548 const baseWidth = 1548
const nextScale = Math.min(1, availableWidth / baseWidth) const nextScale = Math.min(1, availableWidth / baseWidth)
@@ -818,7 +849,16 @@ function closeRightPanel() {
} }
function setScreenMode(mode) { function setScreenMode(mode) {
if (mode === 'admin' && !isAdmin.value) {
return
}
screenMode.value = mode screenMode.value = mode
if (mode === 'admin') {
void loadAdminDashboard()
}
closeLeftPanel() closeLeftPanel()
} }
@@ -924,6 +964,7 @@ async function applyAuthSuccess(data) {
}) })
await loadGoals() await loadGoals()
await hydratePlannerRecordsFromApi() await hydratePlannerRecordsFromApi()
await loadAdminDashboard()
syncProfileForm() syncProfileForm()
closeAuthDialog() closeAuthDialog()
} }
@@ -973,6 +1014,7 @@ async function restoreAuthSession() {
}) })
await loadGoals() await loadGoals()
await hydratePlannerRecordsFromApi() await hydratePlannerRecordsFromApi()
await loadAdminDashboard()
syncProfileForm() syncProfileForm()
} catch (error) { } catch (error) {
authToken.value = '' authToken.value = ''
@@ -991,6 +1033,21 @@ function logout() {
goals.value = [] goals.value = []
goalQuery.value = '' goalQuery.value = ''
goalMessage.value = '' goalMessage.value = ''
adminMessage.value = ''
adminUsers.value = []
adminRecentLogins.value = []
adminOverview.value = {
totalUsers: 0,
totalAdmins: 0,
verifiedUsers: 0,
activeUsers30d: 0,
newUsers7d: 0,
totalPlannerEntries: 0,
totalGoals: 0,
}
if (screenMode.value === 'admin') {
screenMode.value = 'planner'
}
setSyncFeedback('local', '로그인 후 클라우드 저장을 사용할 수 있습니다.', { setSyncFeedback('local', '로그인 후 클라우드 저장을 사용할 수 있습니다.', {
visible: false, visible: false,
}) })
@@ -1000,6 +1057,29 @@ function logout() {
resetPasswordForm() resetPasswordForm()
} }
async function loadAdminDashboard() {
if (!authToken.value || !isAdmin.value) {
adminUsers.value = []
adminRecentLogins.value = []
adminMessage.value = ''
return
}
adminBusy.value = true
try {
const result = await fetchAdminOverview(authToken.value)
adminOverview.value = result.summary
adminUsers.value = result.users
adminRecentLogins.value = result.recentLogins
adminMessage.value = ''
} catch (error) {
adminMessage.value = error.message || '관리자 데이터를 불러오지 못했습니다.'
} finally {
adminBusy.value = false
}
}
async function loadGoals() { async function loadGoals() {
if (!authToken.value) { if (!authToken.value) {
return return
@@ -1190,6 +1270,10 @@ async function submitProfileForm() {
user: result.user, user: result.user,
}) })
syncProfileForm() syncProfileForm()
await loadAdminDashboard()
if (!isAdmin.value && screenMode.value === 'admin') {
screenMode.value = 'planner'
}
profileMessage.value = '프로필이 저장되었습니다.' profileMessage.value = '프로필이 저장되었습니다.'
} catch (error) { } catch (error) {
profileMessage.value = error.message || '프로필을 저장하지 못했습니다.' profileMessage.value = error.message || '프로필을 저장하지 못했습니다.'
@@ -1441,14 +1525,20 @@ onBeforeUnmount(() => {
class="scrollbar-hide print-hidden rounded-[28px] border border-white/60 bg-white/70 p-5 backdrop-blur sm:p-6 xl:h-full xl:overflow-y-auto" class="scrollbar-hide print-hidden rounded-[28px] border border-white/60 bg-white/70 p-5 backdrop-blur sm:p-6 xl:h-full xl:overflow-y-auto"
> >
<div class="space-y-6"> <div class="space-y-6">
<div class="space-y-2"> <div class="overflow-hidden rounded-[28px] border border-stone-200 bg-[linear-gradient(145deg,rgba(255,255,255,0.96),rgba(246,238,228,0.92))] p-5 shadow-[0_18px_40px_rgba(28,25,23,0.06)]">
<p class="text-[11px] font-bold uppercase tracking-[0.28em] text-stone-500">10 Minute Planner</p> <div class="flex items-center justify-between gap-3">
<h1 class="text-2xl font-semibold tracking-[-0.04em] text-stone-900"> <p class="text-[11px] font-bold uppercase tracking-[0.28em] text-stone-500">10 Minute Planner</p>
다이어리처럼 보이되,<br>앱답게 빠르게 이동하는 플래너 </div>
</h1> <div class="mt-6 space-y-3">
<p class="text-sm font-medium leading-6 text-stone-600"> <h1 class="text-[28px] font-semibold leading-[1.1] tracking-[-0.06em] text-stone-900">
A5 본문을 최대한 넓게 쓰기 위해 상단 헤더 대신 왼쪽 사이드 내비게이션 구조로 정리했습니다. 오늘의 흐름을
</p> <br>
가볍게 기록하세요
</h1>
<p class="max-w-[18rem] text-[13px] font-semibold leading-6 tracking-[0.02em] text-stone-600">
짧게 적고, 바로 체크하고, 날짜를 넘기며 하루의 리듬을 이어가는 플래너입니다.
</p>
</div>
</div> </div>
<section class="rounded-[24px] border border-stone-200 bg-white/80 p-4"> <section class="rounded-[24px] border border-stone-200 bg-white/80 p-4">
@@ -1456,6 +1546,7 @@ onBeforeUnmount(() => {
<p class="text-[10px] font-bold tracking-[0.16em] text-stone-500">SIGNED IN</p> <p class="text-[10px] font-bold tracking-[0.16em] text-stone-500">SIGNED IN</p>
<p class="mt-2 text-lg font-semibold tracking-[-0.03em] text-stone-900">{{ currentUser.nickname }}</p> <p class="mt-2 text-lg font-semibold tracking-[-0.03em] text-stone-900">{{ currentUser.nickname }}</p>
<p class="mt-1 text-sm font-semibold text-stone-500">{{ currentUser.email }}</p> <p class="mt-1 text-sm font-semibold text-stone-500">{{ currentUser.email }}</p>
<p class="mt-2 text-[11px] font-bold tracking-[0.16em] text-stone-400">{{ currentUser.role === 'admin' ? 'ADMIN' : 'USER' }}</p>
<button <button
type="button" type="button"
class="mt-4 w-full rounded-full border border-stone-200 px-4 py-3 text-xs font-bold tracking-[0.16em] text-stone-600 transition hover:border-stone-400 hover:text-ink" class="mt-4 w-full rounded-full border border-stone-200 px-4 py-3 text-xs font-bold tracking-[0.16em] text-stone-600 transition hover:border-stone-400 hover:text-ink"
@@ -1527,11 +1618,21 @@ onBeforeUnmount(() => {
<p class="text-xs font-bold tracking-[0.18em]">SETTINGS</p> <p class="text-xs font-bold tracking-[0.18em]">SETTINGS</p>
<p class="mt-1 text-[11px] font-semibold tracking-[0.04em]" :class="screenMode === 'settings' ? 'text-stone-200' : 'text-stone-500'">계정 정보와 비밀번호 수정</p> <p class="mt-1 text-[11px] font-semibold tracking-[0.04em]" :class="screenMode === 'settings' ? 'text-stone-200' : 'text-stone-500'">계정 정보와 비밀번호 수정</p>
</button> </button>
<button
v-if="isAdmin"
type="button"
class="rounded-[20px] border px-4 py-4 text-left transition"
:class="screenMode === 'admin' ? 'border-stone-900 bg-stone-900 text-white shadow-[0_12px_24px_rgba(28,25,23,0.18)]' : 'border-stone-200 bg-white text-stone-700 hover:border-stone-400'"
@click="setScreenMode('admin')"
>
<p class="text-xs font-bold tracking-[0.18em]">ADMIN</p>
<p class="mt-1 text-[11px] font-semibold tracking-[0.04em]" :class="screenMode === 'admin' ? 'text-stone-200' : 'text-stone-500'">사용자 현황과 운영 통계 확인</p>
</button>
</div> </div>
</section> </section>
<section v-if="isAuthenticated && screenMode === 'planner'" class="rounded-[24px] border border-stone-200 bg-white/80 p-4"> <section v-if="isAuthenticated && screenMode === 'planner'" class="rounded-[24px] border border-stone-200 bg-white/80 p-4">
<p class="text-[10px] font-bold tracking-[0.16em] text-stone-500">VIEW</p> <p class="text-[10px] font-bold tracking-[0.16em] text-stone-500">보기 방식</p>
<div class="mt-4 grid gap-2"> <div class="mt-4 grid gap-2">
<button <button
type="button" type="button"
@@ -1539,7 +1640,7 @@ onBeforeUnmount(() => {
:class="viewMode === 'focus' ? 'bg-stone-900 text-white' : 'bg-stone-100 text-stone-500'" :class="viewMode === 'focus' ? 'bg-stone-900 text-white' : 'bg-stone-100 text-stone-500'"
@click="setViewMode('focus')" @click="setViewMode('focus')"
> >
1 PAGE + INFO 1페이지 + 정보
</button> </button>
<button <button
type="button" type="button"
@@ -1547,47 +1648,47 @@ onBeforeUnmount(() => {
:class="viewMode === 'spread' ? 'bg-stone-900 text-white' : 'bg-stone-100 text-stone-500'" :class="viewMode === 'spread' ? 'bg-stone-900 text-white' : 'bg-stone-100 text-stone-500'"
@click="setViewMode('spread')" @click="setViewMode('spread')"
> >
2 PAGE SPREAD 2페이지 펼침
</button> </button>
</div> </div>
</section> </section>
<section v-if="isAuthenticated && screenMode === 'planner'" class="rounded-[24px] border border-stone-200 bg-white/80 p-4"> <section v-if="isAuthenticated && screenMode === 'planner'" class="rounded-[24px] border border-stone-200 bg-white/80 p-4">
<p class="text-[10px] font-bold tracking-[0.16em] text-stone-500">DAY MOVE</p> <p class="text-[10px] font-bold tracking-[0.16em] text-stone-500">날짜 이동</p>
<div class="mt-4 grid gap-2"> <div class="mt-4 grid gap-2">
<button <button
type="button" type="button"
class="rounded-2xl border border-stone-200 px-4 py-3 text-xs font-bold tracking-[0.14em] text-stone-600 transition hover:border-stone-400 hover:text-ink" class="rounded-2xl border border-stone-200 px-4 py-3 text-xs font-bold tracking-[0.14em] text-stone-600 transition hover:border-stone-400 hover:text-ink"
@click="shiftDate(-1)" @click="shiftDate(-1)"
> >
PREV DAY 이전
</button> </button>
<button <button
type="button" type="button"
class="rounded-2xl border border-stone-200 px-4 py-3 text-xs font-bold tracking-[0.14em] text-stone-600 transition hover:border-stone-400 hover:text-ink" class="rounded-2xl border border-stone-200 px-4 py-3 text-xs font-bold tracking-[0.14em] text-stone-600 transition hover:border-stone-400 hover:text-ink"
@click="shiftDate(1)" @click="shiftDate(1)"
> >
NEXT DAY 다음
</button> </button>
</div> </div>
</section> </section>
<section v-if="isAuthenticated && screenMode === 'planner'" class="rounded-[24px] border border-stone-200 bg-white/80 p-4"> <section v-if="isAuthenticated && screenMode === 'planner'" class="rounded-[24px] border border-stone-200 bg-white/80 p-4">
<p class="text-[10px] font-bold tracking-[0.16em] text-stone-500">PRINT</p> <p class="text-[10px] font-bold tracking-[0.16em] text-stone-500">인쇄</p>
<div class="mt-4 grid gap-2"> <div class="mt-4 grid gap-2">
<button <button
type="button" type="button"
class="rounded-2xl border border-stone-200 px-4 py-3 text-xs font-bold tracking-[0.14em] text-stone-600 transition hover:border-stone-400 hover:text-ink" class="rounded-2xl border border-stone-200 px-4 py-3 text-xs font-bold tracking-[0.14em] text-stone-600 transition hover:border-stone-400 hover:text-ink"
@click="printSelectedPlanner('single')" @click="printSelectedPlanner('single')"
> >
PRINT 1-UP 1 인쇄
</button> </button>
<button <button
type="button" type="button"
class="rounded-2xl border border-stone-200 px-4 py-3 text-xs font-bold tracking-[0.14em] text-stone-600 transition hover:border-stone-400 hover:text-ink" class="rounded-2xl border border-stone-200 px-4 py-3 text-xs font-bold tracking-[0.14em] text-stone-600 transition hover:border-stone-400 hover:text-ink"
@click="printSelectedPlanner('double')" @click="printSelectedPlanner('double')"
> >
PRINT 2-UP 2 인쇄
</button> </button>
</div> </div>
</section> </section>
@@ -1614,20 +1715,30 @@ onBeforeUnmount(() => {
</button> </button>
</div> </div>
<div class="space-y-2"> <div class="overflow-hidden rounded-[28px] border border-stone-200 bg-[linear-gradient(145deg,rgba(255,255,255,0.96),rgba(246,238,228,0.92))] p-5 shadow-[0_18px_40px_rgba(28,25,23,0.06)]">
<p class="text-[11px] font-bold uppercase tracking-[0.28em] text-stone-500">10 Minute Planner</p> <div class="flex items-center justify-between gap-3">
<h1 class="text-2xl font-semibold tracking-[-0.04em] text-stone-900"> <p class="text-[11px] font-bold uppercase tracking-[0.28em] text-stone-500">10 Minute Planner</p>
다이어리처럼 보이되,<br>앱답게 빠르게 이동하는 플래너 <span class="rounded-full border border-stone-200 bg-white/80 px-3 py-1 text-[10px] font-bold tracking-[0.18em] text-stone-500">
</h1> DAILY FLOW
<p class="text-sm font-medium leading-6 text-stone-600"> </span>
A5 본문을 최대한 넓게 쓰기 위해 상단 헤더 대신 왼쪽 사이드 내비게이션 구조로 정리했습니다. </div>
</p> <div class="mt-6 space-y-3">
<h1 class="text-[28px] font-semibold leading-[1.1] tracking-[-0.06em] text-stone-900">
오늘의 흐름을
<br>
가볍게 기록하세요
</h1>
<p class="max-w-[18rem] text-[13px] font-semibold leading-6 tracking-[0.02em] text-stone-600">
짧게 적고, 바로 체크하고, 날짜를 넘기며 하루의 리듬을 이어가는 플래너입니다.
</p>
</div>
</div> </div>
<section class="rounded-[24px] border border-stone-200 bg-white/80 p-4"> <section class="rounded-[24px] border border-stone-200 bg-white/80 p-4">
<p class="text-[10px] font-bold tracking-[0.16em] text-stone-500">SIGNED IN</p> <p class="text-[10px] font-bold tracking-[0.16em] text-stone-500">SIGNED IN</p>
<p class="mt-2 text-lg font-semibold tracking-[-0.03em] text-stone-900">{{ currentUser.nickname }}</p> <p class="mt-2 text-lg font-semibold tracking-[-0.03em] text-stone-900">{{ currentUser.nickname }}</p>
<p class="mt-1 text-sm font-semibold text-stone-500">{{ currentUser.email }}</p> <p class="mt-1 text-sm font-semibold text-stone-500">{{ currentUser.email }}</p>
<p class="mt-2 text-[11px] font-bold tracking-[0.16em] text-stone-400">{{ currentUser.role === 'admin' ? 'ADMIN' : 'USER' }}</p>
<button <button
type="button" type="button"
class="mt-4 w-full rounded-full border border-stone-200 px-4 py-3 text-xs font-bold tracking-[0.16em] text-stone-600 transition hover:border-stone-400 hover:text-ink" class="mt-4 w-full rounded-full border border-stone-200 px-4 py-3 text-xs font-bold tracking-[0.16em] text-stone-600 transition hover:border-stone-400 hover:text-ink"
@@ -1676,11 +1787,21 @@ onBeforeUnmount(() => {
<p class="text-xs font-bold tracking-[0.18em]">SETTINGS</p> <p class="text-xs font-bold tracking-[0.18em]">SETTINGS</p>
<p class="mt-1 text-[11px] font-semibold tracking-[0.04em]" :class="screenMode === 'settings' ? 'text-stone-200' : 'text-stone-500'">계정 정보와 비밀번호 수정</p> <p class="mt-1 text-[11px] font-semibold tracking-[0.04em]" :class="screenMode === 'settings' ? 'text-stone-200' : 'text-stone-500'">계정 정보와 비밀번호 수정</p>
</button> </button>
<button
v-if="isAdmin"
type="button"
class="rounded-[20px] border px-4 py-4 text-left transition"
:class="screenMode === 'admin' ? 'border-stone-900 bg-stone-900 text-white shadow-[0_12px_24px_rgba(28,25,23,0.18)]' : 'border-stone-200 bg-white text-stone-700 hover:border-stone-400'"
@click="setScreenMode('admin')"
>
<p class="text-xs font-bold tracking-[0.18em]">ADMIN</p>
<p class="mt-1 text-[11px] font-semibold tracking-[0.04em]" :class="screenMode === 'admin' ? 'text-stone-200' : 'text-stone-500'">사용자 현황과 운영 통계 확인</p>
</button>
</div> </div>
</section> </section>
<section v-if="screenMode === 'planner'" class="rounded-[24px] border border-stone-200 bg-white/80 p-4"> <section v-if="screenMode === 'planner'" class="rounded-[24px] border border-stone-200 bg-white/80 p-4">
<p class="text-[10px] font-bold tracking-[0.16em] text-stone-500">VIEW</p> <p class="text-[10px] font-bold tracking-[0.16em] text-stone-500">보기 방식</p>
<div class="mt-4 grid gap-2"> <div class="mt-4 grid gap-2">
<button <button
type="button" type="button"
@@ -1688,7 +1809,7 @@ onBeforeUnmount(() => {
:class="viewMode === 'focus' ? 'bg-stone-900 text-white' : 'bg-stone-100 text-stone-500'" :class="viewMode === 'focus' ? 'bg-stone-900 text-white' : 'bg-stone-100 text-stone-500'"
@click="setViewMode('focus')" @click="setViewMode('focus')"
> >
1 PAGE + INFO 1페이지 + 정보
</button> </button>
<button <button
type="button" type="button"
@@ -1696,47 +1817,47 @@ onBeforeUnmount(() => {
:class="viewMode === 'spread' ? 'bg-stone-900 text-white' : 'bg-stone-100 text-stone-500'" :class="viewMode === 'spread' ? 'bg-stone-900 text-white' : 'bg-stone-100 text-stone-500'"
@click="setViewMode('spread')" @click="setViewMode('spread')"
> >
2 PAGE SPREAD 2페이지 펼침
</button> </button>
</div> </div>
</section> </section>
<section v-if="screenMode === 'planner'" class="rounded-[24px] border border-stone-200 bg-white/80 p-4"> <section v-if="screenMode === 'planner'" class="rounded-[24px] border border-stone-200 bg-white/80 p-4">
<p class="text-[10px] font-bold tracking-[0.16em] text-stone-500">DAY MOVE</p> <p class="text-[10px] font-bold tracking-[0.16em] text-stone-500">날짜 이동</p>
<div class="mt-4 grid gap-2"> <div class="mt-4 grid gap-2">
<button <button
type="button" type="button"
class="rounded-2xl border border-stone-200 px-4 py-3 text-xs font-bold tracking-[0.14em] text-stone-600 transition hover:border-stone-400 hover:text-ink" class="rounded-2xl border border-stone-200 px-4 py-3 text-xs font-bold tracking-[0.14em] text-stone-600 transition hover:border-stone-400 hover:text-ink"
@click="shiftDate(-1)" @click="shiftDate(-1)"
> >
PREV DAY 이전
</button> </button>
<button <button
type="button" type="button"
class="rounded-2xl border border-stone-200 px-4 py-3 text-xs font-bold tracking-[0.14em] text-stone-600 transition hover:border-stone-400 hover:text-ink" class="rounded-2xl border border-stone-200 px-4 py-3 text-xs font-bold tracking-[0.14em] text-stone-600 transition hover:border-stone-400 hover:text-ink"
@click="shiftDate(1)" @click="shiftDate(1)"
> >
NEXT DAY 다음
</button> </button>
</div> </div>
</section> </section>
<section v-if="screenMode === 'planner'" class="rounded-[24px] border border-stone-200 bg-white/80 p-4"> <section v-if="screenMode === 'planner'" class="rounded-[24px] border border-stone-200 bg-white/80 p-4">
<p class="text-[10px] font-bold tracking-[0.16em] text-stone-500">PRINT</p> <p class="text-[10px] font-bold tracking-[0.16em] text-stone-500">인쇄</p>
<div class="mt-4 grid gap-2"> <div class="mt-4 grid gap-2">
<button <button
type="button" type="button"
class="rounded-2xl border border-stone-200 px-4 py-3 text-xs font-bold tracking-[0.14em] text-stone-600 transition hover:border-stone-400 hover:text-ink" class="rounded-2xl border border-stone-200 px-4 py-3 text-xs font-bold tracking-[0.14em] text-stone-600 transition hover:border-stone-400 hover:text-ink"
@click="printSelectedPlanner('single')" @click="printSelectedPlanner('single')"
> >
PRINT 1-UP 1 인쇄
</button> </button>
<button <button
type="button" type="button"
class="rounded-2xl border border-stone-200 px-4 py-3 text-xs font-bold tracking-[0.14em] text-stone-600 transition hover:border-stone-400 hover:text-ink" class="rounded-2xl border border-stone-200 px-4 py-3 text-xs font-bold tracking-[0.14em] text-stone-600 transition hover:border-stone-400 hover:text-ink"
@click="printSelectedPlanner('double')" @click="printSelectedPlanner('double')"
> >
PRINT 2-UP 2 인쇄
</button> </button>
</div> </div>
</section> </section>
@@ -1815,7 +1936,7 @@ onBeforeUnmount(() => {
/> />
</Transition> </Transition>
<div class="scrollbar-hide print-target border border-white/60 bg-white/45 shadow-[0_18px_60px_rgba(28,25,23,0.06)] xl:h-full xl:min-h-0 xl:overflow-y-auto xl:pr-3" :class="isCompactMobile ? 'rounded-[24px] p-3' : 'rounded-[28px] p-4'"> <div class="scrollbar-hide print-target border border-white/60 bg-white/45 xl:h-full xl:min-h-0 xl:overflow-y-auto xl:pr-3" :class="isCompactMobile ? 'rounded-[24px] p-3' : 'rounded-[28px] p-4'">
<div v-if="isOverlayFocusSidebar" class="mb-4 flex justify-end"> <div v-if="isOverlayFocusSidebar" class="mb-4 flex justify-end">
<button <button
type="button" type="button"
@@ -1832,7 +1953,7 @@ onBeforeUnmount(() => {
:dday="plannerDday" :dday="plannerDday"
:show-dday="showPlannerDday" :show-dday="showPlannerDday"
:comment="planner.comment" :comment="planner.comment"
:total-time="formatTotalTime(planner)" :total-time="formatTotalTimeKorean(planner)"
:tasks="planner.tasks" :tasks="planner.tasks"
:memo="planner.memo" :memo="planner.memo"
:hours="hours" :hours="hours"
@@ -1849,7 +1970,7 @@ onBeforeUnmount(() => {
<aside <aside
v-if="showInlineFocusSidebar" v-if="showInlineFocusSidebar"
class="scrollbar-hide print-hidden rounded-[28px] border border-white/60 bg-[#f7f2ea]/95 p-3 shadow-[0_18px_60px_rgba(28,25,23,0.12)] xl:h-full xl:min-h-0 xl:overflow-y-auto" class="scrollbar-hide print-hidden rounded-[28px] border border-white/60 bg-[#f7f2ea]/95 p-3 xl:h-full xl:min-h-0 xl:overflow-y-auto"
:class="[focusSidebarOuterClass]" :class="[focusSidebarOuterClass]"
> >
<div v-if="!showInlineFocusSidebar" class="mb-3 flex items-center justify-between rounded-[18px] border border-stone-200 bg-white/90 px-4 py-3"> <div v-if="!showInlineFocusSidebar" class="mb-3 flex items-center justify-between rounded-[18px] border border-stone-200 bg-white/90 px-4 py-3">
@@ -1912,13 +2033,13 @@ onBeforeUnmount(() => {
<button <button
type="button" type="button"
class="relative h-8 w-16 shrink-0 rounded-full transition-colors duration-300 ease-out disabled:cursor-not-allowed disabled:opacity-50" class="relative h-8 w-16 shrink-0 rounded-full transition-colors duration-300 ease-out disabled:cursor-not-allowed disabled:opacity-50"
:class="planner.goalEnabled ? 'bg-stone-900' : 'bg-stone-300'" :class="plannerGoalToggleOn ? 'bg-stone-900' : 'bg-stone-300'"
:disabled="!hasActiveGoalForSelectedDate" :disabled="!hasActiveGoalForSelectedDate"
@click="updateGoalEnabled(planner, !planner.goalEnabled)" @click="updateGoalEnabled(planner, !planner.goalEnabled)"
> >
<span <span
class="absolute left-1 top-1 h-6 w-6 transform-gpu rounded-full bg-white shadow-sm transition-transform duration-300 ease-out will-change-transform" class="absolute left-1 top-1 h-6 w-6 transform-gpu rounded-full bg-white shadow-sm transition-transform duration-300 ease-out will-change-transform"
:class="planner.goalEnabled ? 'translate-x-8' : 'translate-x-0'" :class="plannerGoalToggleOn ? 'translate-x-8' : 'translate-x-0'"
/> />
</button> </button>
</div> </div>
@@ -1973,7 +2094,7 @@ onBeforeUnmount(() => {
<span class="ml-1" :class="secondaryDateDisplay.weekdayTone">{{ secondaryDateDisplay.weekday }}</span> <span class="ml-1" :class="secondaryDateDisplay.weekdayTone">{{ secondaryDateDisplay.weekday }}</span>
</p> </p>
<p class="text-[11px] font-semibold tracking-[0.08em] text-stone-600"> <p class="text-[11px] font-semibold tracking-[0.08em] text-stone-600">
내일의 작업은 "{{ secondaryPlanner.tasks.find((task) => task.title.trim())?.title || '새 작업 추가' }}" 시작합니다. {{ nextDaySummary }}
</p> </p>
</div> </div>
</article> </article>
@@ -2011,7 +2132,7 @@ onBeforeUnmount(() => {
<Transition name="drawer-right"> <Transition name="drawer-right">
<aside <aside
v-if="!showInlineFocusSidebar && rightPanelOpen" v-if="!showInlineFocusSidebar && rightPanelOpen"
class="scrollbar-hide print-hidden fixed inset-y-4 right-4 z-40 w-[min(360px,calc(100vw-2rem))] overflow-y-auto rounded-[28px] border border-white/60 bg-[#f7f2ea]/95 p-3 shadow-[0_18px_60px_rgba(28,25,23,0.12)]" class="scrollbar-hide print-hidden fixed inset-y-4 right-4 z-40 w-[min(360px,calc(100vw-2rem))] overflow-y-auto rounded-[28px] border border-white/60 bg-[#f7f2ea]/95 p-3"
> >
<div class="mb-3 flex items-center justify-between rounded-[18px] border border-stone-200 bg-white/90 px-4 py-3"> <div class="mb-3 flex items-center justify-between rounded-[18px] border border-stone-200 bg-white/90 px-4 py-3">
<p class="text-[11px] font-bold tracking-[0.18em] text-ink">SIDE PANEL</p> <p class="text-[11px] font-bold tracking-[0.18em] text-ink">SIDE PANEL</p>
@@ -2073,13 +2194,13 @@ onBeforeUnmount(() => {
<button <button
type="button" type="button"
class="relative h-8 w-16 shrink-0 rounded-full transition-colors duration-300 ease-out disabled:cursor-not-allowed disabled:opacity-50" class="relative h-8 w-16 shrink-0 rounded-full transition-colors duration-300 ease-out disabled:cursor-not-allowed disabled:opacity-50"
:class="planner.goalEnabled ? 'bg-stone-900' : 'bg-stone-300'" :class="plannerGoalToggleOn ? 'bg-stone-900' : 'bg-stone-300'"
:disabled="!hasActiveGoalForSelectedDate" :disabled="!hasActiveGoalForSelectedDate"
@click="updateGoalEnabled(planner, !planner.goalEnabled)" @click="updateGoalEnabled(planner, !planner.goalEnabled)"
> >
<span <span
class="absolute left-1 top-1 h-6 w-6 transform-gpu rounded-full bg-white shadow-sm transition-transform duration-300 ease-out will-change-transform" class="absolute left-1 top-1 h-6 w-6 transform-gpu rounded-full bg-white shadow-sm transition-transform duration-300 ease-out will-change-transform"
:class="planner.goalEnabled ? 'translate-x-8' : 'translate-x-0'" :class="plannerGoalToggleOn ? 'translate-x-8' : 'translate-x-0'"
/> />
</button> </button>
</div> </div>
@@ -2134,7 +2255,7 @@ onBeforeUnmount(() => {
<span class="ml-1" :class="secondaryDateDisplay.weekdayTone">{{ secondaryDateDisplay.weekday }}</span> <span class="ml-1" :class="secondaryDateDisplay.weekdayTone">{{ secondaryDateDisplay.weekday }}</span>
</p> </p>
<p class="text-[11px] font-semibold tracking-[0.08em] text-stone-600"> <p class="text-[11px] font-semibold tracking-[0.08em] text-stone-600">
내일의 작업은 "{{ secondaryPlanner.tasks.find((task) => task.title.trim())?.title || '새 작업 추가' }}" 시작합니다. {{ nextDaySummary }}
</p> </p>
</div> </div>
</article> </article>
@@ -2173,7 +2294,7 @@ onBeforeUnmount(() => {
<section <section
v-else-if="screenMode === 'planner'" v-else-if="screenMode === 'planner'"
class="scrollbar-hide print-hidden overflow-x-auto rounded-[28px] border border-white/60 bg-white/45 p-4 shadow-[0_18px_60px_rgba(28,25,23,0.06)] sm:p-6 xl:h-full xl:overflow-y-auto" class="scrollbar-hide print-hidden overflow-x-auto rounded-[28px] border border-white/60 bg-white/45 p-4 sm:p-6 xl:h-full xl:overflow-y-auto"
> >
<div class="mx-auto flex gap-6" :style="spreadCanvasStyle"> <div class="mx-auto flex gap-6" :style="spreadCanvasStyle">
<div class="print-target overflow-hidden" :style="spreadPageFrameStyle"> <div class="print-target overflow-hidden" :style="spreadPageFrameStyle">
@@ -2185,7 +2306,7 @@ onBeforeUnmount(() => {
:dday="plannerDday" :dday="plannerDday"
:show-dday="showPlannerDday" :show-dday="showPlannerDday"
:comment="planner.comment" :comment="planner.comment"
:total-time="formatTotalTime(planner)" :total-time="formatTotalTimeKorean(planner)"
:tasks="planner.tasks" :tasks="planner.tasks"
:memo="planner.memo" :memo="planner.memo"
:hours="hours" :hours="hours"
@@ -2208,7 +2329,7 @@ onBeforeUnmount(() => {
:dday="''" :dday="''"
:show-dday="false" :show-dday="false"
:comment="secondaryPlanner.comment" :comment="secondaryPlanner.comment"
:total-time="formatTotalTime(secondaryPlanner)" :total-time="formatTotalTimeKorean(secondaryPlanner)"
:tasks="secondaryPlanner.tasks" :tasks="secondaryPlanner.tasks"
:memo="secondaryPlanner.memo" :memo="secondaryPlanner.memo"
:hours="hours" :hours="hours"
@@ -2260,6 +2381,16 @@ onBeforeUnmount(() => {
@submit:password="submitPasswordForm" @submit:password="submitPasswordForm"
/> />
<AdminDashboard
v-else-if="screenMode === 'admin' && isAdmin"
class="scrollbar-hide print-hidden xl:h-full xl:overflow-y-auto"
:summary="adminOverview"
:users="adminUsers"
:recent-logins="adminRecentLogins"
:busy="adminBusy"
:message="adminMessage"
/>
<StatsDashboard <StatsDashboard
v-else v-else
class="scrollbar-hide print-hidden xl:h-full xl:overflow-y-auto" class="scrollbar-hide print-hidden xl:h-full xl:overflow-y-auto"
@@ -2284,7 +2415,7 @@ onBeforeUnmount(() => {
:dday="plannerDday" :dday="plannerDday"
:show-dday="showPlannerDday" :show-dday="showPlannerDday"
:comment="planner.comment" :comment="planner.comment"
:total-time="formatTotalTime(planner)" :total-time="formatTotalTimeKorean(planner)"
:tasks="planner.tasks" :tasks="planner.tasks"
:memo="planner.memo" :memo="planner.memo"
:hours="hours" :hours="hours"
@@ -2309,7 +2440,7 @@ onBeforeUnmount(() => {
:dday="plannerDday" :dday="plannerDday"
:show-dday="showPlannerDday" :show-dday="showPlannerDday"
:comment="planner.comment" :comment="planner.comment"
:total-time="formatTotalTime(planner)" :total-time="formatTotalTimeKorean(planner)"
:tasks="planner.tasks" :tasks="planner.tasks"
:memo="planner.memo" :memo="planner.memo"
:hours="hours" :hours="hours"
@@ -2331,7 +2462,7 @@ onBeforeUnmount(() => {
:dday="''" :dday="''"
:show-dday="false" :show-dday="false"
:comment="secondaryPlanner.comment" :comment="secondaryPlanner.comment"
:total-time="formatTotalTime(secondaryPlanner)" :total-time="formatTotalTimeKorean(secondaryPlanner)"
:tasks="secondaryPlanner.tasks" :tasks="secondaryPlanner.tasks"
:memo="secondaryPlanner.memo" :memo="secondaryPlanner.memo"
:hours="hours" :hours="hours"

View File

@@ -0,0 +1,185 @@
<script setup>
const props = defineProps({
summary: {
type: Object,
required: true,
},
users: {
type: Array,
required: true,
},
recentLogins: {
type: Array,
required: true,
},
busy: {
type: Boolean,
default: false,
},
message: {
type: String,
default: '',
},
})
function formatDate(value) {
if (!value) {
return '기록 없음'
}
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return '기록 없음'
}
return new Intl.DateTimeFormat('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
}).format(date)
}
</script>
<template>
<section class="grid gap-6">
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<article class="rounded-[28px] border border-white/60 bg-white/80 p-6">
<p class="text-[11px] font-bold uppercase tracking-[0.24em] text-stone-500">Total Users</p>
<p class="mt-4 text-4xl font-semibold tracking-[-0.06em] text-stone-900">{{ summary.totalUsers }}</p>
<p class="mt-2 text-sm font-semibold text-stone-500">현재 가입된 전체 계정 </p>
</article>
<article class="rounded-[28px] border border-white/60 bg-white/80 p-6">
<p class="text-[11px] font-bold uppercase tracking-[0.24em] text-stone-500">Active 30 Days</p>
<p class="mt-4 text-4xl font-semibold tracking-[-0.06em] text-stone-900">{{ summary.activeUsers30d }}</p>
<p class="mt-2 text-sm font-semibold text-stone-500">최근 30 안에 접속한 사용자</p>
</article>
<article class="rounded-[28px] border border-white/60 bg-white/80 p-6">
<p class="text-[11px] font-bold uppercase tracking-[0.24em] text-stone-500">Planner Entries</p>
<p class="mt-4 text-4xl font-semibold tracking-[-0.06em] text-stone-900">{{ summary.totalPlannerEntries }}</p>
<p class="mt-2 text-sm font-semibold text-stone-500">전체 작성된 날짜 문서 </p>
</article>
<article class="rounded-[28px] border border-white/60 bg-white/80 p-6">
<p class="text-[11px] font-bold uppercase tracking-[0.24em] text-stone-500">Verified / Admin</p>
<p class="mt-4 text-3xl font-semibold tracking-[-0.06em] text-stone-900">{{ summary.verifiedUsers }} / {{ summary.totalAdmins }}</p>
<p class="mt-2 text-sm font-semibold text-stone-500">인증 완료 계정 / 관리자 </p>
</article>
</div>
<div class="grid gap-6 xl:grid-cols-[320px_minmax(0,1fr)]">
<aside class="rounded-[28px] border border-white/60 bg-white/75 p-6">
<p class="text-[11px] font-bold uppercase tracking-[0.24em] text-stone-500">운영 요약</p>
<div class="mt-5 space-y-4">
<div class="rounded-2xl border border-stone-200 bg-white px-4 py-4">
<p class="text-[10px] font-bold tracking-[0.18em] text-stone-500">최근 7 신규 가입</p>
<p class="mt-2 text-2xl font-semibold tracking-[-0.05em] text-stone-900">{{ summary.newUsers7d }}</p>
</div>
<div class="rounded-2xl border border-stone-200 bg-white px-4 py-4">
<p class="text-[10px] font-bold tracking-[0.18em] text-stone-500">전체 목표 </p>
<p class="mt-2 text-2xl font-semibold tracking-[-0.05em] text-stone-900">{{ summary.totalGoals }}</p>
</div>
</div>
<div class="mt-6">
<p class="text-[11px] font-bold uppercase tracking-[0.24em] text-stone-500">최근 접속</p>
<div class="mt-4 space-y-3">
<article
v-for="user in recentLogins"
:key="`recent-${user.id}`"
class="rounded-2xl border border-stone-200 bg-white px-4 py-4"
>
<div class="flex items-center justify-between gap-3">
<p class="text-sm font-semibold text-stone-900">{{ user.nickname }}</p>
<span class="rounded-full bg-stone-100 px-2 py-1 text-[10px] font-bold tracking-[0.12em] text-stone-600">
{{ user.role }}
</span>
</div>
<p class="mt-1 text-xs font-semibold text-stone-500">{{ user.email }}</p>
<p class="mt-3 text-[11px] font-semibold text-stone-600">{{ formatDate(user.lastLoginAt) }}</p>
</article>
<p v-if="recentLogins.length === 0" class="rounded-2xl border border-dashed border-stone-300 bg-white/80 px-4 py-4 text-sm font-semibold text-stone-500">
아직 접속 기록이 없습니다.
</p>
</div>
</div>
</aside>
<section class="rounded-[28px] border border-white/60 bg-white/75 p-6">
<div class="flex items-center justify-between gap-4">
<div>
<p class="text-[11px] font-bold uppercase tracking-[0.24em] text-stone-500">사용자 목록</p>
<p class="mt-2 text-sm font-semibold text-stone-500">최종 접속일, 작성 문서 , 목표 수를 번에 있습니다.</p>
</div>
<div v-if="busy" class="rounded-full bg-stone-900 px-4 py-2 text-[11px] font-bold tracking-[0.16em] text-white">
불러오는 중...
</div>
</div>
<p
v-if="message"
class="mt-4 rounded-2xl border border-stone-300 bg-white/80 px-4 py-3 text-sm font-semibold leading-6 text-stone-700"
>
{{ message }}
</p>
<div class="mt-5 overflow-hidden rounded-[24px] border border-stone-200 bg-white">
<div class="hidden grid-cols-[84px_minmax(0,1.4fr)_120px_150px_110px_100px_150px] gap-3 border-b border-stone-200 bg-[#f8f4ed] px-5 py-4 text-[10px] font-bold uppercase tracking-[0.18em] text-stone-500 xl:grid">
<span>ID</span>
<span>사용자</span>
<span>권한</span>
<span>최종 접속</span>
<span>문서 </span>
<span>목표 </span>
<span>상태</span>
</div>
<div class="divide-y divide-stone-200">
<article
v-for="user in users"
:key="user.id"
class="px-5 py-4"
>
<div class="grid gap-3 xl:grid-cols-[84px_minmax(0,1.4fr)_120px_150px_110px_100px_150px] xl:items-center">
<p class="text-xs font-bold tracking-[0.14em] text-stone-500">#{{ user.id }}</p>
<div>
<p class="text-sm font-semibold text-stone-900">{{ user.nickname }}</p>
<p class="mt-1 text-xs font-semibold text-stone-500">{{ user.email }}</p>
</div>
<p class="text-sm font-semibold text-stone-700">{{ user.role }}</p>
<p class="text-sm font-semibold text-stone-700">{{ formatDate(user.lastLoginAt) }}</p>
<p class="text-sm font-semibold text-stone-700">{{ user.plannerEntryCount }}</p>
<p class="text-sm font-semibold text-stone-700">{{ user.goalCount }}</p>
<div class="flex flex-wrap items-center gap-2">
<span
class="rounded-full px-3 py-1 text-[10px] font-bold tracking-[0.14em]"
:class="user.isActiveRecently ? 'bg-emerald-100 text-emerald-700' : 'bg-stone-100 text-stone-500'"
>
{{ user.isActiveRecently ? '활동 중' : '휴면 가능성' }}
</span>
<span
class="rounded-full px-3 py-1 text-[10px] font-bold tracking-[0.14em]"
:class="user.emailVerifiedAt ? 'bg-sky-100 text-sky-700' : 'bg-amber-100 text-amber-700'"
>
{{ user.emailVerifiedAt ? '이메일 인증' : '미인증' }}
</span>
</div>
</div>
</article>
<div
v-if="!busy && users.length === 0"
class="px-5 py-10 text-center text-sm font-semibold text-stone-500"
>
표시할 사용자가 없습니다.
</div>
</div>
</div>
</section>
</div>
</section>
</template>

View File

@@ -40,23 +40,23 @@ function selectYear(year) {
</script> </script>
<template> <template>
<section class="rounded-[24px] border border-stone-200 bg-white/82 p-5 shadow-[0_12px_36px_rgba(28,25,23,0.05)]"> <section class="rounded-[24px] border border-stone-200 bg-white/82 p-4 shadow-[0_12px_36px_rgba(28,25,23,0.05)] sm:p-5">
<div class="relative mb-4 flex items-start justify-between gap-4"> <div class="relative mb-4 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
<div class="min-w-0"> <div class="min-w-0">
<h2 class="text-[11px] font-bold tracking-[0.22em] text-ink">CALENDAR</h2> <h2 class="text-[11px] font-bold tracking-[0.22em] text-ink">CALENDAR</h2>
<div class="mt-2 flex items-center gap-3"> <div class="mt-2 flex items-center gap-2 sm:gap-3">
<button <button
type="button" type="button"
class="rounded-full border border-stone-200 px-2 py-1 text-xs font-bold text-stone-600 transition hover:border-stone-400 hover:text-ink" class="flex h-9 w-9 items-center justify-center rounded-full border border-stone-200 text-xs font-bold text-stone-600 transition hover:border-stone-400 hover:text-ink sm:h-auto sm:w-auto sm:px-2 sm:py-1"
@click="emit('shift-month', -1)" @click="emit('shift-month', -1)"
> >
</button> </button>
<div class="flex min-w-0 items-center gap-2"> <div class="flex min-w-0 items-center gap-1.5 sm:gap-2">
<p class="text-base font-semibold tracking-[-0.04em] text-stone-900">{{ monthLabel }}</p> <p class="text-[15px] font-semibold tracking-[-0.04em] text-stone-900 sm:text-base">{{ monthLabel }}</p>
<button <button
type="button" type="button"
class="rounded-full px-2 py-1 text-[11px] font-semibold tracking-[0.16em] text-stone-500 transition hover:bg-stone-100 hover:text-ink" class="rounded-full px-2 py-1 text-[10px] font-semibold tracking-[0.16em] text-stone-500 transition hover:bg-stone-100 hover:text-ink sm:text-[11px]"
@click="isYearPickerOpen = !isYearPickerOpen" @click="isYearPickerOpen = !isYearPickerOpen"
> >
{{ yearLabel }} {{ yearLabel }}
@@ -64,7 +64,7 @@ function selectYear(year) {
</div> </div>
<button <button
type="button" type="button"
class="rounded-full border border-stone-200 px-2 py-1 text-xs font-bold text-stone-600 transition hover:border-stone-400 hover:text-ink" class="flex h-9 w-9 items-center justify-center rounded-full border border-stone-200 text-xs font-bold text-stone-600 transition hover:border-stone-400 hover:text-ink sm:h-auto sm:w-auto sm:px-2 sm:py-1"
@click="emit('shift-month', 1)" @click="emit('shift-month', 1)"
> >
@@ -73,7 +73,7 @@ function selectYear(year) {
</div> </div>
<button <button
type="button" type="button"
class="shrink-0 rounded-full border border-stone-200 px-3 py-2 text-[10px] font-bold tracking-[0.14em] text-stone-600 transition hover:border-stone-400 hover:text-ink" class="shrink-0 self-start rounded-full border border-stone-200 px-3 py-2 text-[10px] font-bold tracking-[0.14em] text-stone-600 transition hover:border-stone-400 hover:text-ink sm:self-auto"
@click="emit('go-today')" @click="emit('go-today')"
> >
TODAY TODAY
@@ -81,7 +81,7 @@ function selectYear(year) {
<div <div
v-if="isYearPickerOpen" v-if="isYearPickerOpen"
class="absolute right-0 top-14 z-10 w-[220px] rounded-2xl border border-stone-200 bg-white p-4 shadow-lg" class="absolute left-0 right-0 top-[88px] z-10 rounded-2xl border border-stone-200 bg-white p-4 shadow-lg sm:left-auto sm:right-0 sm:top-14 sm:w-[220px]"
> >
<div class="mb-3 flex items-center justify-between"> <div class="mb-3 flex items-center justify-between">
<button <button
@@ -116,7 +116,7 @@ function selectYear(year) {
</div> </div>
</div> </div>
</div> </div>
<div class="mb-3 grid grid-cols-7 gap-2 text-center text-[10px] font-bold tracking-[0.12em] text-stone-400"> <div class="mb-3 grid grid-cols-7 gap-1.5 text-center text-[9px] font-bold tracking-[0.1em] text-stone-400 sm:gap-2 sm:text-[10px] sm:tracking-[0.12em]">
<span <span
v-for="weekday in ['일', '월', '화', '수', '목', '금', '토']" v-for="weekday in ['일', '월', '화', '수', '목', '금', '토']"
:key="weekday" :key="weekday"
@@ -124,28 +124,30 @@ function selectYear(year) {
{{ weekday }} {{ weekday }}
</span> </span>
</div> </div>
<div class="grid grid-cols-7 gap-2"> <div class="grid grid-cols-7 gap-1.5 sm:gap-2">
<button <div
v-for="day in days" v-for="day in days"
:key="day.key" :key="day.key"
type="button" class="flex items-center justify-center"
class="aspect-square rounded-full border text-[11px] font-semibold transition"
:class="[
day.key === selectedKey
? 'border-ink bg-ink text-white'
: 'border-stone-200 bg-stone-50 text-stone-700 hover:border-stone-400 hover:bg-white',
day.isCurrentMonth ? '' : 'opacity-35',
]"
@click="emit('select', day.date)"
> >
<span class="relative flex h-full w-full items-center justify-center"> <button
type="button"
class="relative flex h-10 w-10 shrink-0 items-center justify-center rounded-full border text-[10px] font-semibold transition sm:h-[44px] sm:w-[44px] sm:text-[11px]"
:class="[
day.key === selectedKey
? 'border-ink bg-ink text-white'
: 'border-stone-200 bg-stone-50 text-stone-700 hover:border-stone-400 hover:bg-white',
day.isCurrentMonth ? '' : 'opacity-35',
]"
@click="emit('select', day.date)"
>
<span>{{ day.label }}</span> <span>{{ day.label }}</span>
<span <span
v-if="markedKeys.includes(day.key)" v-if="markedKeys.includes(day.key)"
class="absolute bottom-[3px] h-[5px] w-[5px] rounded-full bg-red-500" class="absolute bottom-[3px] h-1 w-1 rounded-full bg-red-500 sm:h-[5px] sm:w-[5px]"
/> />
</span> </button>
</button> </div>
</div> </div>
</section> </section>
</template> </template>

View File

@@ -121,8 +121,8 @@ onBeforeUnmount(() => {
<article <article
class="planner-sheet flex w-full max-w-[762px] flex-col gap-3 bg-paper px-4 py-4 text-[10px] font-bold tracking-[0.16em] text-ink shadow-paper sm:px-8 sm:py-8 lg:px-12 lg:py-12" class="planner-sheet flex w-full max-w-[762px] flex-col gap-3 bg-paper px-4 py-4 text-[10px] font-bold tracking-[0.16em] text-ink shadow-paper sm:px-8 sm:py-8 lg:px-12 lg:py-12"
> >
<div class="flex flex-col gap-4 py-3 sm:py-[18px]"> <div class="planner-sheet__meta flex flex-col gap-4 py-3 sm:py-[18px]">
<div class="flex flex-col gap-3 sm:gap-4" :class="props.showDday ? 'sm:flex-row' : ''"> <div class="planner-sheet__meta-top flex flex-col gap-3 sm:gap-4" :class="props.showDday ? 'sm:flex-row' : ''">
<div class="relative min-h-[82px] border-t border-ink px-[10px] pt-[10px]" :class="props.showDday ? 'w-full sm:w-[394px] sm:flex-1' : 'w-full flex-1'"> <div class="relative min-h-[82px] border-t border-ink px-[10px] pt-[10px]" :class="props.showDday ? 'w-full sm:w-[394px] sm:flex-1' : 'w-full flex-1'">
<span class="absolute -top-2 left-0 bg-paper px-[2px] text-muted">YEAR / MONTH / DAY</span> <span class="absolute -top-2 left-0 bg-paper px-[2px] text-muted">YEAR / MONTH / DAY</span>
<p class="pt-5 text-[11px] tracking-[0.2em] text-ink sm:pt-6 sm:text-sm"> <p class="pt-5 text-[11px] tracking-[0.2em] text-ink sm:pt-6 sm:text-sm">
@@ -132,10 +132,22 @@ onBeforeUnmount(() => {
</div> </div>
<div v-if="props.showDday" class="relative min-h-[82px] w-full border-t border-ink px-[10px] pt-[10px] sm:w-[210px]"> <div v-if="props.showDday" class="relative min-h-[82px] w-full border-t border-ink px-[10px] pt-[10px] sm:w-[210px]">
<span class="absolute -top-2 left-0 bg-paper px-[2px] text-muted">D-DAY</span> <span class="absolute -top-2 left-0 bg-paper px-[2px] text-muted">D-DAY</span>
<p class="pt-5 text-[11px] tracking-[0.2em] text-ink sm:pt-6 sm:text-sm">{{ dday }}</p> <p
class="pt-5 text-[11px] tracking-[0.14em] text-ink sm:pt-6 sm:text-sm"
style="
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
word-break: break-word;
"
>
{{ dday }}
</p>
</div> </div>
</div> </div>
<div class="flex flex-col gap-3 border-b border-ink pb-3 sm:gap-4 sm:pb-[18px] lg:flex-row"> <div class="planner-sheet__meta-bottom flex flex-col gap-3 border-b border-ink pb-3 sm:gap-4 sm:pb-[18px] lg:flex-row">
<div class="relative min-h-[82px] w-full flex-1 border-t border-ink px-[10px] pt-[10px] lg:w-[394px]"> <div class="relative min-h-[82px] w-full flex-1 border-t border-ink px-[10px] pt-[10px] lg:w-[394px]">
<span class="absolute -top-2 left-0 bg-paper px-[2px] text-muted">COMMENT</span> <span class="absolute -top-2 left-0 bg-paper px-[2px] text-muted">COMMENT</span>
<textarea <textarea
@@ -147,14 +159,14 @@ onBeforeUnmount(() => {
/> />
</div> </div>
<div class="relative min-h-[82px] w-full border-t border-ink px-[10px] pt-[10px] lg:w-[210px]"> <div class="relative min-h-[82px] w-full border-t border-ink px-[10px] pt-[10px] lg:w-[210px]">
<span class="absolute -top-2 left-0 bg-paper px-[2px] text-muted">TOTAL TIME</span> <span class="absolute -top-2 left-0 bg-paper px-[2px] text-muted"> 시간</span>
<p class="pt-5 text-[11px] tracking-[0.2em] text-ink sm:pt-6 sm:text-sm">{{ totalTime }}</p> <p class="pt-5 text-[11px] tracking-[0.2em] text-ink sm:pt-6 sm:text-sm">{{ totalTime }}</p>
</div> </div>
</div> </div>
</div> </div>
<div class="flex flex-col gap-5 py-[10px] lg:flex-row lg:gap-4"> <div class="planner-sheet__body flex flex-col gap-5 py-[10px] lg:flex-row lg:gap-4">
<div class="flex w-full flex-1 flex-col gap-7 sm:gap-9 lg:w-[394px]"> <div class="planner-sheet__lists flex w-full flex-1 flex-col gap-7 sm:gap-9 lg:w-[394px]">
<section class="relative"> <section class="relative">
<div class="absolute -top-[9px] left-0 bg-paper px-[2px] text-muted">TASKS</div> <div class="absolute -top-[9px] left-0 bg-paper px-[2px] text-muted">TASKS</div>
<div class="border-t border-ink"> <div class="border-t border-ink">
@@ -225,10 +237,10 @@ onBeforeUnmount(() => {
</section> </section>
</div> </div>
<section class="relative w-full shrink-0 lg:w-[210px]"> <section class="planner-sheet__timetable relative w-full shrink-0 lg:w-[210px]">
<div class="absolute -top-2 left-0 bg-paper px-[2px] text-muted">TIME TABLE</div> <div class="absolute -top-2 left-0 bg-paper px-[2px] text-muted">TIME TABLE</div>
<div class="overflow-x-auto pb-1"> <div class="planner-sheet__timetable-scroll overflow-x-auto pb-1">
<div class="min-w-[210px] border-t border-ink"> <div class="planner-sheet__timetable-grid min-w-[210px] border-t border-ink">
<div <div
v-for="(hour, index) in hours" v-for="(hour, index) in hours"
:key="`${hour}-${index}`" :key="`${hour}-${index}`"

View File

@@ -71,12 +71,12 @@ function updatePasswordField(field, event) {
<p class="mt-1 text-sm font-semibold text-stone-500">{{ user.email }}</p> <p class="mt-1 text-sm font-semibold text-stone-500">{{ user.email }}</p>
</div> </div>
</div> </div>
<div class="mt-6 space-y-3 rounded-[24px] border border-stone-200 bg-[#fbf7f0] p-4"> <!-- <div class="mt-6 space-y-3 rounded-[24px] border border-stone-200 bg-[#fbf7f0] p-4">
<p class="text-[10px] font-bold tracking-[0.18em] text-stone-500">PROFILE NOTE</p> <p class="text-[10px] font-bold tracking-[0.18em] text-stone-500">PROFILE NOTE</p>
<p class="text-sm font-semibold leading-6 text-stone-700"> <p class="text-sm font-semibold leading-6 text-stone-700">
썸네일 이미지는 다음 단계에서 붙이는 편이 자연스럽습니다. 이번 단계에서는 계정 정보 수정과 비밀번호 변경 흐름을 먼저 안정화합니다. ...
</p> </p>
</div> </div> -->
</aside> </aside>
<div class="grid gap-6"> <div class="grid gap-6">

21
src/lib/adminApi.js Normal file
View File

@@ -0,0 +1,21 @@
import { buildApiUrl, toUserFacingApiError } from './apiBase'
async function request(path, token) {
const response = await fetch(buildApiUrl(path), {
headers: {
Authorization: `Bearer ${token}`,
},
})
const data = await response.json().catch(() => ({}))
if (!response.ok) {
throw new Error(toUserFacingApiError(data, '관리자 데이터를 불러오지 못했습니다.'))
}
return data
}
export async function fetchAdminOverview(token) {
return request('/api/admin/overview', token)
}

View File

@@ -22,6 +22,10 @@
} }
@media print { @media print {
@page {
margin: 0;
}
html, html,
body { body {
background: #ffffff !important; background: #ffffff !important;
@@ -125,7 +129,7 @@
width: 762px !important; width: 762px !important;
max-width: none !important; max-width: none !important;
transform-origin: top left; transform-origin: top left;
transform: scale(0.978); transform: scale(0.982);
box-shadow: none !important; box-shadow: none !important;
background: #ffffff !important; background: #ffffff !important;
} }
@@ -134,7 +138,7 @@
width: 762px !important; width: 762px !important;
max-width: none !important; max-width: none !important;
transform-origin: top left; transform-origin: top left;
transform: scale(0.684); transform: scale(0.6894);
box-shadow: none !important; box-shadow: none !important;
background: #ffffff !important; background: #ffffff !important;
} }
@@ -143,6 +147,57 @@
-webkit-print-color-adjust: exact; -webkit-print-color-adjust: exact;
print-color-adjust: exact; print-color-adjust: exact;
} }
.planner-sheet__meta-top,
.planner-sheet__meta-bottom,
.planner-sheet__body {
display: flex !important;
flex-direction: row !important;
gap: 16px !important;
}
.planner-sheet__meta {
gap: 14px !important;
padding-top: 10px !important;
padding-bottom: 10px !important;
}
.planner-sheet__meta-top > div,
.planner-sheet__meta-bottom > div {
min-height: 78px !important;
}
.planner-sheet__lists {
width: 394px !important;
flex: 1 1 auto !important;
}
.planner-sheet__timetable {
width: 210px !important;
flex-shrink: 0 !important;
}
.planner-sheet__timetable-scroll {
overflow: visible !important;
padding-bottom: 0 !important;
}
.planner-sheet__timetable-grid {
min-width: 210px !important;
}
.planner-sheet textarea {
height: auto !important;
min-height: 48px !important;
overflow: visible !important;
padding-top: 4px !important;
line-height: 1.55 !important;
}
.print-paper--double .print-sheet-frame {
align-items: flex-start !important;
justify-content: flex-start !important;
}
} }
@media screen { @media screen {