diff --git a/README.md b/README.md index ee6cd60..13754e1 100644 --- a/README.md +++ b/README.md @@ -79,16 +79,17 @@ npm run dev ## 스크립트 -| 명령어 | 설명 | -| ---------------------- | ----------------------------------------- | -| `npm run dev` | 개발 서버 실행 | -| `npm run build` | 프로덕션 빌드 | -| `npm run start` | 프로덕션 서버 실행 | -| `npm run lint` | ESLint 검사 | -| `npm run typecheck` | TypeScript 타입 검사 | -| `npm run format` | Prettier 포맷 적용 | -| `npm run format:check` | Prettier 포맷 검사 | -| `npm run check` | lint + typecheck + format:check 일괄 검사 | +| 명령어 | 설명 | +| ---------------------- | --------------------------------------------------- | +| `npm run dev` | 개발 서버 실행 | +| `npm run build` | 프로덕션 빌드 | +| `npm run start` | 프로덕션 서버 실행 | +| `npm run lint` | ESLint 검사 | +| `npm run lint:tw` | Tailwind spacing 임의값 프리셋 검사 | +| `npm run typecheck` | TypeScript 타입 검사 | +| `npm run format` | Prettier 포맷 적용 | +| `npm run format:check` | Prettier 포맷 검사 | +| `npm run check` | lint + lint:tw + typecheck + format:check 일괄 검사 | ## 프로젝트 구조 diff --git a/package.json b/package.json index 822019d..494d2db 100644 --- a/package.json +++ b/package.json @@ -7,10 +7,11 @@ "build": "next build", "start": "next start", "lint": "eslint", + "lint:tw": "node scripts/check-tailwind-spacing.mjs", "typecheck": "tsc --noEmit", "format": "prettier --write .", "format:check": "prettier --check .", - "check": "npm run lint && npm run typecheck && npm run format:check" + "check": "npm run lint && npm run lint:tw && npm run typecheck && npm run format:check" }, "dependencies": { "@hookform/resolvers": "^5.4.0", diff --git a/public/workspaces/empty-workspace.png b/public/workspaces/empty-workspace.png new file mode 100644 index 0000000..8842723 Binary files /dev/null and b/public/workspaces/empty-workspace.png differ diff --git a/scripts/check-tailwind-spacing.mjs b/scripts/check-tailwind-spacing.mjs new file mode 100644 index 0000000..deaac2d --- /dev/null +++ b/scripts/check-tailwind-spacing.mjs @@ -0,0 +1,87 @@ +/* eslint-disable no-console -- CLI 검사 스크립트로 콘솔 출력이 본 기능이다 */ +// Tailwind spacing 임의값 게이트 +// gap / p* / m* / space 계열에 `-[Npx]`를 쓰면 0.25 단위 프리셋(N÷4)으로 변환하도록 강제한다. +// 이 규칙은 eslint/prettier로 잡히지 않고 editor의 Tailwind IntelliSense 경고로만 뜨므로, +// CI(`npm run check`)에서 막아 누구든 놓치지 않게 한다. +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { join } from 'node:path'; + +const ROOT = 'src'; + +// spacing scale를 그대로 쓰는 유틸만 대상으로 한다 (width/height/inset 등 치수는 제외) +const SPACING_PREFIXES = [ + 'gap-x', + 'gap-y', + 'gap', + 'px', + 'py', + 'pt', + 'pb', + 'pl', + 'pr', + 'p', + 'mx', + 'my', + 'mt', + 'mb', + 'ml', + 'mr', + 'm', + 'space-x', + 'space-y', +]; + +// 예: gap-[15px], sm:p-[21px], -mt-[8px], group-hover:gap-x-[12px] +const PATTERN = new RegExp( + // variant prefix는 유한 반복({0,10})으로 바운딩해 중첩 quantifier 백트래킹(ReDoS)을 방지한다 + '(?:^|[\\s"\'\\x60])(?:[a-z][a-z-]*:){0,10}(-?)(' + + SPACING_PREFIXES.join('|') + + ')-\\[(\\d+(?:\\.\\d+)?)px\\]', + 'g', +); + +function walk(dir) { + const files = []; + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) files.push(...walk(full)); + else if (/\.(ts|tsx|js|jsx)$/.test(entry)) files.push(full); + } + return files; +} + +// 주석(//, /* */) 안의 예시 클래스가 오탐되지 않도록 스캔 전 주석을 제거한다 (줄 번호는 유지) +function stripComments(source) { + return source + .replace(/\/\*[\s\S]*?\*\//g, (block) => block.replace(/[^\n]/g, ' ')) + .replace(/\/\/[^\n]*/g, ''); +} + +const violations = []; +for (const file of walk(ROOT)) { + const lines = stripComments(readFileSync(file, 'utf8')).split('\n'); + lines.forEach((line, index) => { + for (const match of line.matchAll(PATTERN)) { + const [, sign, prefix, px] = match; + violations.push({ + file, + line: index + 1, + from: `${sign}${prefix}-[${px}px]`, + to: `${sign}${prefix}-${Number(px) / 4}`, + }); + } + }); +} + +if (violations.length > 0) { + console.error( + '\n✗ Tailwind spacing 임의값이 발견되었습니다. 0.25 단위 프리셋으로 변환하세요 (N÷4):\n', + ); + for (const v of violations) { + console.error(` ${v.file}:${v.line} ${v.from} → ${v.to}`); + } + console.error(`\n총 ${violations.length}건. 예) gap-[15px] → gap-3.75, p-[21px] → p-5.25\n`); + process.exit(1); +} + +console.log('✓ Tailwind spacing 프리셋 검사 통과'); diff --git a/src/app/workspaces/page.tsx b/src/app/workspaces/page.tsx new file mode 100644 index 0000000..cb8b260 --- /dev/null +++ b/src/app/workspaces/page.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from 'next'; +import { WorkspacesPage } from '@/views/workspaces'; + +export const metadata: Metadata = { + title: '내 워크스페이스 · Syncly', +}; + +export default function Page() { + return ; +} diff --git a/src/entities/work-schedule/lib/count-schedules-by-weekday.ts b/src/entities/work-schedule/lib/count-schedules-by-weekday.ts index 8dd0dae..91633d8 100644 --- a/src/entities/work-schedule/lib/count-schedules-by-weekday.ts +++ b/src/entities/work-schedule/lib/count-schedules-by-weekday.ts @@ -13,7 +13,7 @@ export function countSchedulesByWeekday({ config, weekday, }: CountSchedulesByWeekdayParams): Record { -const counts = Object.fromEntries(config.shifts.map((shift) => [shift.id, 0])); + const counts = Object.fromEntries(config.shifts.map((shift) => [shift.id, 0])); const uniqueEntries = new Map(); diff --git a/src/entities/work-schedule/lib/get-work-members-by-weekday.ts b/src/entities/work-schedule/lib/get-work-members-by-weekday.ts index 5bdae88..50c40a6 100644 --- a/src/entities/work-schedule/lib/get-work-members-by-weekday.ts +++ b/src/entities/work-schedule/lib/get-work-members-by-weekday.ts @@ -16,7 +16,9 @@ export function getWorkMembersByWeekday({ config, weekday, }: GetWorkMembersByWeekdayParams): WorkspaceMember[] { - const offShiftIds = new Set(config.shifts.filter((shift) => shift.isOff).map((shift) => shift.id)); + const offShiftIds = new Set( + config.shifts.filter((shift) => shift.isOff).map((shift) => shift.id), + ); const workingUserIds = new Set( schedule diff --git a/src/entities/workspace/api/get-my-workspaces.ts b/src/entities/workspace/api/get-my-workspaces.ts new file mode 100644 index 0000000..16a4d6c --- /dev/null +++ b/src/entities/workspace/api/get-my-workspaces.ts @@ -0,0 +1,51 @@ +// get_my_workspaces RPC의 Mock 구현 +// 백엔드 준비 시 supabase.rpc('get_my_workspaces') 호출로 교체한다 (반환 shape 동일) +import type { WorkspaceSummary } from '../model/workspace.types'; + +// updated_at은 호출 시점 기준 상대값으로 생성해 "최근 활동" 표기가 자연스럽게 유지되도록 한다 +type MockWorkspaceSeed = Omit & { + activityMinutesAgo: number; +}; + +const MOCK_WORKSPACE_SEEDS: MockWorkspaceSeed[] = [ + { + id: 'ws-capstone-design', + name: '캡스톤 디자인 팀', + purpose: 'team-project', + member_count: 5, + task_count: 12, + done_task_count: 7, + activityMinutesAgo: 10, + }, + { + id: 'ws-fitto-app', + name: 'Fitto 앱 개발팀', + purpose: 'side-project', + member_count: 4, + task_count: 24, + done_task_count: 18, + activityMinutesAgo: 60, + }, + { + id: 'ws-cafe-gray', + name: '카페 그레이 운영', + purpose: 'store-operation', + member_count: 6, + task_count: 8, + done_task_count: 5, + activityMinutesAgo: 180, + }, +]; + +export async function getMyWorkspaces(): Promise { + const now = Date.now(); + + return MOCK_WORKSPACE_SEEDS.map(({ activityMinutesAgo, ...summary }) => ({ + ...summary, + progress: + summary.task_count === 0 + ? 0 + : Math.round((summary.done_task_count / summary.task_count) * 100), + updated_at: new Date(now - activityMinutesAgo * 60_000).toISOString(), + })); +} diff --git a/src/entities/workspace/config/purpose.ts b/src/entities/workspace/config/purpose.ts new file mode 100644 index 0000000..8337fbd --- /dev/null +++ b/src/entities/workspace/config/purpose.ts @@ -0,0 +1,35 @@ +// purpose별 표시 정보 — ERD에 템플릿 테이블이 없으므로 라벨/아이콘/포인트 컬러는 프론트에서 매핑한다 +import { GraduationCap, LayoutGrid, ShoppingBag, Zap } from 'lucide-react'; +import type { LucideIcon } from 'lucide-react'; +import type { WorkspacePurpose } from '../model/workspace.types'; + +interface WorkspacePurposeMeta { + label: string; + icon: LucideIcon; + gradient: string; +} + +export const WORKSPACE_PURPOSE_META: Record = { + 'team-project': { + label: '팀 프로젝트', + icon: GraduationCap, + gradient: 'linear-gradient(135deg, #8e51ff 0%, #615fff 100%)', + }, + 'side-project': { + label: '사이드 프로젝트', + icon: Zap, + gradient: 'linear-gradient(135deg, #2b7fff 0%, #00b8db 100%)', + }, + 'store-operation': { + label: '매장 운영', + icon: ShoppingBag, + gradient: 'linear-gradient(135deg, #fe9a00 0%, #ff6900 100%)', + }, +}; + +// purpose가 매핑에 없을 때(백엔드 연동 후 값 불일치 등) 사용하는 중립 표시 정보 +export const FALLBACK_PURPOSE_META: WorkspacePurposeMeta = { + label: '워크스페이스', + icon: LayoutGrid, + gradient: 'linear-gradient(135deg, #7b7fa8 0%, #5b4ee8 100%)', +}; diff --git a/src/entities/workspace/index.ts b/src/entities/workspace/index.ts index 86cdcdd..659fd91 100644 --- a/src/entities/workspace/index.ts +++ b/src/entities/workspace/index.ts @@ -1,3 +1,5 @@ -// 목업 워크스페이스 데이터와 타입의 공개 API입니다. -export type { Workspace, WorkspacePurpose } from './model/workspace.types'; +// workspace 엔티티 Public API +export type { Workspace, WorkspacePurpose, WorkspaceSummary } from './model/workspace.types'; export { mockWorkspace } from './model/mock-workspace'; +export { WORKSPACE_PURPOSE_META, FALLBACK_PURPOSE_META } from './config/purpose'; +export { getMyWorkspaces } from './api/get-my-workspaces'; diff --git a/src/entities/workspace/model/workspace.types.ts b/src/entities/workspace/model/workspace.types.ts index 9002dc7..0e2b61a 100644 --- a/src/entities/workspace/model/workspace.types.ts +++ b/src/entities/workspace/model/workspace.types.ts @@ -6,3 +6,17 @@ export interface Workspace { name: string; purpose: WorkspacePurpose; } + +// get_my_workspaces RPC 반환 형태 (내 워크스페이스 목록) +// - *_count와 progress는 서버에서 계산되어 내려온다 (progress = done_task_count / task_count * 100, 미저장) +// - updated_at은 workspaces.updated_at으로, 카드의 "최근 활동" 표기에 사용한다 +export interface WorkspaceSummary { + id: string; + name: string; + purpose: WorkspacePurpose; + member_count: number; + task_count: number; + done_task_count: number; + progress: number; + updated_at: string; +} diff --git a/src/features/manage-work-schedule/model/use-work-schedule-state.ts b/src/features/manage-work-schedule/model/use-work-schedule-state.ts index 3fe4135..52fec70 100644 --- a/src/features/manage-work-schedule/model/use-work-schedule-state.ts +++ b/src/features/manage-work-schedule/model/use-work-schedule-state.ts @@ -56,7 +56,11 @@ function completeScheduleEntries({ return [...schedule, ...missingEntries]; } -export function useWorkScheduleState({ initialSchedule, members, config }: UseWorkScheduleStateParams) { +export function useWorkScheduleState({ + initialSchedule, + members, + config, +}: UseWorkScheduleStateParams) { const [schedule, setSchedule] = useState(() => completeScheduleEntries({ schedule: initialSchedule, diff --git a/src/features/manage-work-schedule/ui/WorkShiftSettingsPanel.tsx b/src/features/manage-work-schedule/ui/WorkShiftSettingsPanel.tsx index 3a0e59a..5b90b4c 100644 --- a/src/features/manage-work-schedule/ui/WorkShiftSettingsPanel.tsx +++ b/src/features/manage-work-schedule/ui/WorkShiftSettingsPanel.tsx @@ -1,10 +1,6 @@ // 근무 옵션을 추가, 제거, 정렬하고 시간을 설정할 수 있는 인라인 편집기입니다. import { ChevronDown, ChevronUp } from 'lucide-react'; -import type { - WorkScheduleConfig, - WorkShiftColor, - WorkShiftOption, -} from '@/entities/work-schedule'; +import type { WorkScheduleConfig, WorkShiftColor, WorkShiftOption } from '@/entities/work-schedule'; const shiftColors: WorkShiftColor[] = ['sky', 'violet', 'amber', 'slate', 'emerald', 'rose']; @@ -166,4 +162,4 @@ export function WorkShiftSettingsPanel({ ); -} \ No newline at end of file +} diff --git a/src/shared/lib/date.ts b/src/shared/lib/date.ts new file mode 100644 index 0000000..5d1e0fc --- /dev/null +++ b/src/shared/lib/date.ts @@ -0,0 +1,14 @@ +// 상대 시간 포맷 — "방금 전 / N분 전 / N시간 전 / N일 전" +export function formatRelativeTime(isoDate: string): string { + const diffMs = Date.now() - new Date(isoDate).getTime(); + const minutes = Math.floor(diffMs / 60_000); + + if (minutes < 1) return '방금 전'; + if (minutes < 60) return `${minutes}분 전`; + + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}시간 전`; + + const days = Math.floor(hours / 24); + return `${days}일 전`; +} diff --git a/src/views/workspaces/index.ts b/src/views/workspaces/index.ts new file mode 100644 index 0000000..18b3db1 --- /dev/null +++ b/src/views/workspaces/index.ts @@ -0,0 +1,2 @@ +// workspaces 뷰의 Public API +export { default as WorkspacesPage } from './ui/WorkspacesPage'; diff --git a/src/views/workspaces/ui/WorkspacesPage.tsx b/src/views/workspaces/ui/WorkspacesPage.tsx new file mode 100644 index 0000000..cc3baf9 --- /dev/null +++ b/src/views/workspaces/ui/WorkspacesPage.tsx @@ -0,0 +1,36 @@ +// 내 워크스페이스 페이지 — 헤더 + 참여 중인 워크스페이스 목록(빈 상태 포함)을 조립한다 +import Link from 'next/link'; +import { Plus } from 'lucide-react'; +import { Plus_Jakarta_Sans } from 'next/font/google'; +import { getMyWorkspaces } from '@/entities/workspace'; +import { WorkspaceList } from '@/widgets/workspace-list'; + +// Figma 지정 폰트 — 한글은 시스템 폰트로 fallback된다 +const jakarta = Plus_Jakarta_Sans({ + subsets: ['latin'], + weight: ['400', '600', '700', '800'], +}); + +export default async function WorkspacesPage() { + const workspaces = await getMyWorkspaces(); + + return ( +
+
+
+
+

내 워크스페이스

+

참여 중인 워크스페이스 목록

+
+ + 새 워크스페이스 + +
+ +
+
+ ); +} diff --git a/src/widgets/landing/landing-hero/ui/HeroSection.tsx b/src/widgets/landing/landing-hero/ui/HeroSection.tsx index 266387b..24e0462 100644 --- a/src/widgets/landing/landing-hero/ui/HeroSection.tsx +++ b/src/widgets/landing/landing-hero/ui/HeroSection.tsx @@ -47,7 +47,7 @@ export default function HeroSection() { className="relative z-10 flex flex-col gap-0.75 text-4xl leading-[1.4] font-extrabold tracking-[-1.2px] sm:text-5xl" > 협업은 더 가볍게 - + 성과는 더 빠르게 diff --git a/src/widgets/workspace-list/index.ts b/src/widgets/workspace-list/index.ts new file mode 100644 index 0000000..1ea2279 --- /dev/null +++ b/src/widgets/workspace-list/index.ts @@ -0,0 +1,2 @@ +// workspace-list 위젯의 Public API +export { default as WorkspaceList } from './ui/WorkspaceList'; diff --git a/src/widgets/workspace-list/ui/EmptyWorkspaces.tsx b/src/widgets/workspace-list/ui/EmptyWorkspaces.tsx new file mode 100644 index 0000000..bc3bb64 --- /dev/null +++ b/src/widgets/workspace-list/ui/EmptyWorkspaces.tsx @@ -0,0 +1,24 @@ +import Image from 'next/image'; + +// 참여 중인 워크스페이스가 없을 때의 빈 상태 +export default function EmptyWorkspaces() { + return ( +
+ +

+ 아직 워크스페이스가 없어요 +

+

+ 팀과 함께 사용할 워크스페이스를 만들어 +
+ 프로젝트를 효율적으로 관리해보세요. +

+
+ ); +} diff --git a/src/widgets/workspace-list/ui/WorkspaceCard.tsx b/src/widgets/workspace-list/ui/WorkspaceCard.tsx new file mode 100644 index 0000000..6466151 --- /dev/null +++ b/src/widgets/workspace-list/ui/WorkspaceCard.tsx @@ -0,0 +1,68 @@ +import Link from 'next/link'; +import { Check, ChevronRight, Clock, Users } from 'lucide-react'; +import { formatRelativeTime } from '@/shared/lib/date'; +import { + FALLBACK_PURPOSE_META, + WORKSPACE_PURPOSE_META, + type WorkspaceSummary, +} from '@/entities/workspace'; + +interface WorkspaceCardProps { + workspace: WorkspaceSummary; +} + +// 워크스페이스 요약 카드 — 클릭 시 해당 워크스페이스로 이동(상세 대시보드는 별도 이슈, 지금은 라우팅 스텁) +export default function WorkspaceCard({ workspace }: WorkspaceCardProps) { + const { id, name, purpose, member_count, task_count, done_task_count, progress, updated_at } = + workspace; + // 백엔드 연동 후 DB의 purpose가 유니언과 어긋날 수 있어 중립 메타로 폴백한다 + const meta = WORKSPACE_PURPOSE_META[purpose] ?? FALLBACK_PURPOSE_META; + const PurposeIcon = meta.icon; + + return ( + +
+ +
+
+
+
+

{name}

+

{meta.label}

+
+ +
+
+ + + {member_count}명 + + + + {formatRelativeTime(updated_at)} + + + + {done_task_count}/{task_count} 완료 + +
+
+ 진행률 +
+
+
+ {progress}% +
+
+ + ); +} diff --git a/src/widgets/workspace-list/ui/WorkspaceList.tsx b/src/widgets/workspace-list/ui/WorkspaceList.tsx new file mode 100644 index 0000000..e58ac90 --- /dev/null +++ b/src/widgets/workspace-list/ui/WorkspaceList.tsx @@ -0,0 +1,22 @@ +import type { WorkspaceSummary } from '@/entities/workspace'; +import WorkspaceCard from './WorkspaceCard'; +import EmptyWorkspaces from './EmptyWorkspaces'; + +interface WorkspaceListProps { + workspaces: WorkspaceSummary[]; +} + +// 워크스페이스 목록 — 데이터가 있으면 카드 리스트, 없으면 빈 상태 +export default function WorkspaceList({ workspaces }: WorkspaceListProps) { + if (workspaces.length === 0) { + return ; + } + + return ( +
+ {workspaces.map((workspace) => ( + + ))} +
+ ); +}