From 8216198c88b1e684ae2fa521f51c4b067f34057b Mon Sep 17 00:00:00 2001 From: Nicholai Date: Wed, 17 Sep 2025 11:24:00 -0600 Subject: [PATCH] Scaffolded admin dashboard, added nextauth, cloudflare d1 and R2 --- .clinerules/cicdrules.md | 2 +- .clinerules/context7-requirements.md | 32 + .clinerules/infrarules.md | 2 +- .clinerules/nextjsrules.md | 2 +- .clinerules/shadcn-component-requirements.md | 32 + ...71f60ff260118952a4fe6bba7ffe3b1d909.sqlite | Bin 0 -> 4096 bytes ...0ff260118952a4fe6bba7ffe3b1d909.sqlite-shm | Bin 0 -> 32768 bytes ...0ff260118952a4fe6bba7ffe3b1d909.sqlite-wal | Bin 0 -> 131872 bytes D1_SETUP.md | 169 + __tests__/lib/data-migration.test.ts | 144 + __tests__/lib/validations.test.ts | 92 + admin_dashboard_implementation_plan.md | 214 + app/ClientLayout.tsx | 43 +- app/admin/artists/[id]/page.tsx | 76 + app/admin/artists/new/page.tsx | 16 + app/admin/artists/page.tsx | 397 ++ app/admin/calendar/page.tsx | 458 ++ app/admin/layout.tsx | 65 + app/admin/page.tsx | 149 + app/api/admin/migrate/route.ts | 82 + app/api/admin/stats/route.ts | 143 + app/api/appointments/route.ts | 328 ++ app/api/artists/[id]/route.ts | 164 + app/api/artists/route.ts | 115 + app/api/auth/[...nextauth]/route.ts | 6 + app/api/settings/route.ts | 177 + app/api/upload/route.ts | 169 + app/api/users/route.ts | 102 + app/auth/error/page.tsx | 67 + app/auth/signin/page.tsx | 121 + components/admin/appointment-calendar.tsx | 360 ++ components/admin/artist-form.tsx | 352 ++ components/admin/data-table.tsx | 188 + components/admin/error-boundary.tsx | 187 + components/admin/loading-states.tsx | 251 + components/admin/sidebar.tsx | 161 + components/admin/stats-dashboard.tsx | 375 ++ comprehensive_implementation_plan.md | 350 ++ hooks/use-artists.ts | 224 + hooks/use-file-upload.ts | 281 + lib/auth.ts | 197 + lib/data-migration.ts | 308 + lib/db.ts | 457 ++ lib/env.ts | 53 + lib/r2-upload.ts | 355 ++ lib/upload.ts | 278 + lib/validations.ts | 265 + middleware.ts | 103 + package-lock.json | 5091 ++++++++++++++++- package.json | 36 +- sql/schema.sql | 136 + types/database.ts | 272 + vitest.config.ts | 17 + vitest.setup.ts | 106 + wrangler.toml | 28 + 55 files changed, 13781 insertions(+), 17 deletions(-) create mode 100644 .clinerules/context7-requirements.md create mode 100644 .clinerules/shadcn-component-requirements.md create mode 100644 .wrangler/state/v3/d1/miniflare-D1DatabaseObject/ee4948ccf8de293a26426641085fa71f60ff260118952a4fe6bba7ffe3b1d909.sqlite create mode 100644 .wrangler/state/v3/d1/miniflare-D1DatabaseObject/ee4948ccf8de293a26426641085fa71f60ff260118952a4fe6bba7ffe3b1d909.sqlite-shm create mode 100644 .wrangler/state/v3/d1/miniflare-D1DatabaseObject/ee4948ccf8de293a26426641085fa71f60ff260118952a4fe6bba7ffe3b1d909.sqlite-wal create mode 100644 D1_SETUP.md create mode 100644 __tests__/lib/data-migration.test.ts create mode 100644 __tests__/lib/validations.test.ts create mode 100644 admin_dashboard_implementation_plan.md create mode 100644 app/admin/artists/[id]/page.tsx create mode 100644 app/admin/artists/new/page.tsx create mode 100644 app/admin/artists/page.tsx create mode 100644 app/admin/calendar/page.tsx create mode 100644 app/admin/layout.tsx create mode 100644 app/admin/page.tsx create mode 100644 app/api/admin/migrate/route.ts create mode 100644 app/api/admin/stats/route.ts create mode 100644 app/api/appointments/route.ts create mode 100644 app/api/artists/[id]/route.ts create mode 100644 app/api/artists/route.ts create mode 100644 app/api/auth/[...nextauth]/route.ts create mode 100644 app/api/settings/route.ts create mode 100644 app/api/upload/route.ts create mode 100644 app/api/users/route.ts create mode 100644 app/auth/error/page.tsx create mode 100644 app/auth/signin/page.tsx create mode 100644 components/admin/appointment-calendar.tsx create mode 100644 components/admin/artist-form.tsx create mode 100644 components/admin/data-table.tsx create mode 100644 components/admin/error-boundary.tsx create mode 100644 components/admin/loading-states.tsx create mode 100644 components/admin/sidebar.tsx create mode 100644 components/admin/stats-dashboard.tsx create mode 100644 comprehensive_implementation_plan.md create mode 100644 hooks/use-artists.ts create mode 100644 hooks/use-file-upload.ts create mode 100644 lib/auth.ts create mode 100644 lib/data-migration.ts create mode 100644 lib/db.ts create mode 100644 lib/env.ts create mode 100644 lib/r2-upload.ts create mode 100644 lib/upload.ts create mode 100644 lib/validations.ts create mode 100644 middleware.ts create mode 100644 sql/schema.sql create mode 100644 types/database.ts create mode 100644 vitest.config.ts create mode 100644 vitest.setup.ts create mode 100644 wrangler.toml diff --git a/.clinerules/cicdrules.md b/.clinerules/cicdrules.md index 620268983..378d6ae05 100644 --- a/.clinerules/cicdrules.md +++ b/.clinerules/cicdrules.md @@ -12,6 +12,6 @@ ## Required Workflow - Run Context7 checks for new deps, upgrades, DS changes - Check shadcn registry before custom components -- Use Supabase MCP for all DB ops (incl. migrations) +- Use Cloudflare MCP for all DB ops (incl. migrations) - Plan & Act for complex features; reference existing patterns - Clarify ambiguous requirements early; provide confidence rating \ No newline at end of file diff --git a/.clinerules/context7-requirements.md b/.clinerules/context7-requirements.md new file mode 100644 index 000000000..0aa375e67 --- /dev/null +++ b/.clinerules/context7-requirements.md @@ -0,0 +1,32 @@ +## Brief overview +These rules establish the mandatory use of Context7 MCP server for all development operations, ensuring access to the most current practices and information. This is a global requirement that applies to all projects and development tasks. + +## Context7 MCP dependency +- All library research, documentation lookup, and best practice validation MUST use Context7 MCP server +- Before implementing any new technology, framework, or library, validate current patterns through Context7 +- When encountering errors or implementation issues, consult Context7 for updated solutions and patterns +- Never assume knowledge of libraries or frameworks without Context7 validation + +## Development workflow with Context7 +- Start any new feature or library integration by resolving the library ID through Context7 +- Use Context7 to get current documentation and implementation patterns before coding +- When debugging or troubleshooting, reference Context7 for updated solutions +- Validate testing patterns and mocking strategies through Context7 before implementation + +## Information validation process +- Context7 serves as the authoritative source for current development practices +- All technical decisions should be informed by Context7 documentation and patterns +- When user requests revalidation of approaches, immediately consult Context7 +- Prioritize Context7 guidance over assumed knowledge or outdated practices + +## Implementation standards +- Follow Context7-validated patterns for testing, mocking, and development workflows +- Use Context7 to verify compatibility and current best practices for all dependencies +- Ensure all code patterns align with Context7-provided examples and documentation +- Reference Context7 for proper configuration and setup procedures + +## Error handling and troubleshooting +- When tests fail or implementations don't work as expected, consult Context7 for current solutions +- Use Context7 to validate mocking patterns and testing strategies +- Reference Context7 for proper error handling and debugging approaches +- Always check Context7 for updated patterns when encountering technical issues diff --git a/.clinerules/infrarules.md b/.clinerules/infrarules.md index ed7ee4858..08b4c89cf 100644 --- a/.clinerules/infrarules.md +++ b/.clinerules/infrarules.md @@ -1,7 +1,7 @@ # Data, MCP, Codegen, Migrations, File Uploads ## MCP Requirements -- All DB access (dev/prod/migrations/scripts) via **Supabase MCP** +- All DB access (dev/prod/migrations/scripts) via **Cloudflare MCP** - Context7 MCP required for: new deps, framework upgrades, DS changes - Cache/pin Context7 outputs; PRs require justification to override diff --git a/.clinerules/nextjsrules.md b/.clinerules/nextjsrules.md index 6462e9e7e..386f812be 100644 --- a/.clinerules/nextjsrules.md +++ b/.clinerules/nextjsrules.md @@ -5,7 +5,7 @@ - Tailwind + shadcn/ui (mandatory) - TypeScript only (.ts/.tsx) - State: Zustand (local UI) + React Query (server state) -- DB: Postgres (Docker) **via Supabase MCP only** +- DB: Postgres (Docker) **via Cloudflare MCP only** - VCS: Gitea - MCP: Supabase MCP (DB), Context7 MCP (patterns/updates) diff --git a/.clinerules/shadcn-component-requirements.md b/.clinerules/shadcn-component-requirements.md new file mode 100644 index 000000000..e5c82c194 --- /dev/null +++ b/.clinerules/shadcn-component-requirements.md @@ -0,0 +1,32 @@ +## Brief overview +These rules establish the mandatory use of ShadCN MCP server for all component design and TSX file development. This ensures consistent UI patterns and proper component usage throughout the project. + +## Component design workflow +- All component creation or modification must reference ShadCN MCP server first +- Check ShadCN registry for existing components before creating custom ones +- Use ShadCN MCP to get proper component examples and usage patterns +- Validate component composition and variant usage through ShadCN documentation + +## TSX file development +- Before touching any .tsx file, consult ShadCN MCP for current component patterns +- Use ShadCN MCP to verify proper prop interfaces and component APIs +- Reference ShadCN examples for form handling, data display, and interactive elements +- Follow ShadCN naming conventions and component structure patterns + +## Page design requirements +- All new page designs must start with ShadCN MCP consultation +- Use ShadCN layout patterns and responsive design examples +- Verify accessibility patterns and best practices through ShadCN documentation +- Ensure consistent spacing, typography, and color usage per ShadCN guidelines + +## Component composition standards +- Use ShadCN MCP to validate component combinations and nesting patterns +- Reference ShadCN for proper variant usage and customization approaches +- Follow ShadCN patterns for conditional rendering and state management +- Ensure proper TypeScript integration following ShadCN examples + +## UI consistency enforcement +- All UI elements must align with ShadCN design system principles +- Use ShadCN MCP to verify proper use of design tokens and CSS variables +- Reference ShadCN for animation and transition patterns +- Maintain consistent component behavior across the application diff --git a/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/ee4948ccf8de293a26426641085fa71f60ff260118952a4fe6bba7ffe3b1d909.sqlite b/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/ee4948ccf8de293a26426641085fa71f60ff260118952a4fe6bba7ffe3b1d909.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..0b58f0dca7d6ce95907cb0b322dc4802f04688c0 GIT binary patch literal 4096 zcmWFz^vNtqRY=P(%1ta$FlG>7U}9o$P*7lCU|@t|AVoG{WYDXN;00+HAlr;ljiVtj n8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*O6ovo**AfQj literal 0 HcmV?d00001 diff --git a/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/ee4948ccf8de293a26426641085fa71f60ff260118952a4fe6bba7ffe3b1d909.sqlite-shm b/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/ee4948ccf8de293a26426641085fa71f60ff260118952a4fe6bba7ffe3b1d909.sqlite-shm new file mode 100644 index 0000000000000000000000000000000000000000..439dcc939f35b2892d235054397cd36e43a285e5 GIT binary patch literal 32768 zcmeI*Jx&5)7{u{K@dFfHQDIdODy%$-2QY+CTX_m%#RWJ8N8kuvz|PJK(Bh0*P`)PG zyw87<(YP^?{T*PQ>ixcOYTqq~`rTKb7t_0!x2N@dwtN_`CZo~fqfztX=Z?RBN8MHH>Ylo<9;kap5VPt;SjRL|75dahommug4ts+D@B zUaL21PxU))SAP-&5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY** z5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILK;X{=yn8@W1iU&y@)Gdk z2T2j|-U`V}z*{#YMZil#BrgH4JdqRuZ)}mg1iWuXQUts%NAeQzvL8ti@U9}sOTe3! zBt^iBq9iYYesQZM*9mQ@~dt2?8+%d=Hf%5L3XHfC&OI1$+aWAP`f) J*WXEsz$cyHDnkGO literal 0 HcmV?d00001 diff --git a/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/ee4948ccf8de293a26426641085fa71f60ff260118952a4fe6bba7ffe3b1d909.sqlite-wal b/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/ee4948ccf8de293a26426641085fa71f60ff260118952a4fe6bba7ffe3b1d909.sqlite-wal new file mode 100644 index 0000000000000000000000000000000000000000..6a8d025971a4965c747b9a891409d1a1bab192dd GIT binary patch literal 131872 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 Y2tWV=5P$##AOHafK%k+3IWN%h|4{-p+5i9m literal 0 HcmV?d00001 diff --git a/D1_SETUP.md b/D1_SETUP.md new file mode 100644 index 000000000..b712bd2ca --- /dev/null +++ b/D1_SETUP.md @@ -0,0 +1,169 @@ +# Cloudflare D1 Database Setup Guide + +This guide will help you set up Cloudflare D1 database for the United Tattoo Studio management platform. + +## Prerequisites + +1. **Cloudflare Account** with Workers/Pages access +2. **Wrangler CLI** installed globally: `npm install -g wrangler` +3. **Authenticated with Cloudflare**: `wrangler auth login` + +## Step 1: Create D1 Database + +```bash +# Create the D1 database +npm run db:create + +# This will output something like: +# ✅ Successfully created DB 'united-tattoo-db' in region ENAM +# Created your database using D1's new storage backend. The new storage backend is not yet recommended for production workloads, but backs up your data via point-in-time restore. +# +# [[d1_databases]] +# binding = "DB" +# database_name = "united-tattoo-db" +# database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +``` + +## Step 2: Update wrangler.toml + +Copy the `database_id` from the output above and update your `wrangler.toml`: + +```toml +[[d1_databases]] +binding = "DB" +database_name = "united-tattoo-db" +database_id = "your-actual-database-id-here" # Replace with the ID from step 1 +``` + +## Step 3: Run Database Migrations + +### For Local Development: +```bash +# Create tables in local D1 database +npm run db:migrate:local +``` + +### For Production: +```bash +# Create tables in production D1 database +npm run db:migrate +``` + +## Step 4: Verify Database Setup + +### Check Local Database: +```bash +# List tables in local database +npm run db:studio:local +``` + +### Check Production Database: +```bash +# List tables in production database +npm run db:studio +``` + +## Step 5: Development Workflow + +### Local Development: +```bash +# Start Next.js development server +npm run dev + +# The app will use local SQLite file for development +# Database file: ./local.db +``` + +### Preview with Cloudflare: +```bash +# Build for Cloudflare Pages +npm run pages:build + +# Preview locally with Cloudflare runtime +npm run preview + +# Deploy to Cloudflare Pages +npm run deploy +``` + +## Database Schema + +The database includes the following tables: +- `users` - User accounts and roles +- `artists` - Artist profiles and information +- `portfolio_images` - Artist portfolio images +- `appointments` - Booking and appointment data +- `availability` - Artist availability schedules +- `site_settings` - Studio configuration +- `file_uploads` - File upload metadata + +## Environment Variables + +### Local Development (.env.local): +```env +DATABASE_URL="file:./local.db" +DIRECT_URL="file:./local.db" +``` + +### Production (Cloudflare Pages): +Environment variables are managed through: +1. `wrangler.toml` for public variables +2. Cloudflare Dashboard for secrets +3. D1 database binding automatically available as `env.DB` + +## Useful Commands + +```bash +# Database Management +npm run db:create # Create new D1 database +npm run db:migrate # Run migrations on production DB +npm run db:migrate:local # Run migrations on local DB +npm run db:studio # Query production database +npm run db:studio:local # Query local database + +# Cloudflare Pages +npm run pages:build # Build for Cloudflare Pages +npm run preview # Preview with Cloudflare runtime +npm run deploy # Deploy to Cloudflare Pages + +# Development +npm run dev # Start Next.js dev server +npm run build # Standard Next.js build +``` + +## Troubleshooting + +### Common Issues: + +1. **"Database not found"** + - Make sure you've created the D1 database: `npm run db:create` + - Verify the `database_id` in `wrangler.toml` matches the created database + +2. **"Tables don't exist"** + - Run migrations: `npm run db:migrate:local` (for local) or `npm run db:migrate` (for production) + +3. **"Wrangler not authenticated"** + - Run: `wrangler auth login` + +4. **"Permission denied"** + - Ensure your Cloudflare account has Workers/Pages access + - Check that you're authenticated with the correct account + +### Database Access in Code: + +In your API routes, access the D1 database through the environment binding: + +```typescript +// In API routes (production) +const db = env.DB; // Cloudflare D1 binding + +// For local development, you'll use SQLite +// The lib/db.ts file handles this automatically +``` + +## Next Steps + +After setting up D1: +1. Update the database functions in `lib/db.ts` to use actual D1 queries +2. Test the admin dashboard with real database operations +3. Deploy to Cloudflare Pages for production testing diff --git a/__tests__/lib/data-migration.test.ts b/__tests__/lib/data-migration.test.ts new file mode 100644 index 000000000..638ed0d7b --- /dev/null +++ b/__tests__/lib/data-migration.test.ts @@ -0,0 +1,144 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest' + +// Mock the database using proper Vitest patterns +const mockStmt = { + bind: vi.fn().mockReturnThis(), + run: vi.fn().mockResolvedValue({ success: true, changes: 1 }), + get: vi.fn(), + all: vi.fn().mockResolvedValue({ results: [] }), + first: vi.fn().mockResolvedValue(null), +} + +const mockDB = { + prepare: vi.fn().mockReturnValue(mockStmt), + exec: vi.fn(), +} + +// Mock the entire lib/db module +vi.mock('@/lib/db', () => ({ + getDB: vi.fn(() => mockDB), +})) + +// Mock the artists data with proper structure +vi.mock('@/data/artists', () => ({ + artists: [ + { + id: '1', + name: 'Test Artist', + bio: 'Test bio', + styles: ['Traditional', 'Realism'], + instagram: 'https://instagram.com/testartist', + experience: '5 years', + workImages: ['/test-image.jpg'], + faceImage: '/test-face.jpg', + }, + { + id: '2', + name: 'Another Artist', + bio: 'Another bio', + styles: ['Japanese', 'Blackwork'], + instagram: 'https://instagram.com/anotherartist', + experience: '8 years', + workImages: [], + faceImage: '/another-face.jpg', + }, + ], +})) + +describe('DataMigrator', () => { + let DataMigrator: any + let migrator: any + + beforeEach(async () => { + vi.clearAllMocks() + // Reset mock implementations + mockDB.prepare.mockReturnValue(mockStmt) + mockStmt.first.mockResolvedValue(null) + mockStmt.run.mockResolvedValue({ success: true, changes: 1 }) + + // Import the DataMigrator class after mocks are set up + const module = await import('@/lib/data-migration') + DataMigrator = module.DataMigrator + migrator = new DataMigrator() + }) + + afterEach(() => { + vi.resetAllMocks() + }) + + describe('isMigrationCompleted', () => { + it('should return false when no artists exist', async () => { + mockStmt.first.mockResolvedValueOnce({ count: 0 }) + + const isCompleted = await migrator.isMigrationCompleted() + + expect(isCompleted).toBe(false) + }) + + it('should return true when artists exist', async () => { + mockStmt.first.mockResolvedValueOnce({ count: 2 }) + + const isCompleted = await migrator.isMigrationCompleted() + + expect(isCompleted).toBe(true) + }) + }) + + describe('migrateArtistData', () => { + it('should migrate all artists successfully', async () => { + await migrator.migrateArtistData() + + // Verify user creation calls + expect(mockDB.prepare).toHaveBeenCalledWith( + expect.stringContaining('INSERT OR IGNORE INTO users') + ) + + // Verify artist creation calls + expect(mockDB.prepare).toHaveBeenCalledWith( + expect.stringContaining('INSERT OR IGNORE INTO artists') + ) + + // Verify portfolio image creation calls + expect(mockDB.prepare).toHaveBeenCalledWith( + expect.stringContaining('INSERT OR IGNORE INTO portfolio_images') + ) + }) + + it('should handle errors gracefully', async () => { + mockStmt.run.mockRejectedValueOnce(new Error('Database error')) + + await expect(migrator.migrateArtistData()).rejects.toThrow('Database error') + }) + }) + + describe('clearMigratedData', () => { + it('should clear all data successfully', async () => { + await migrator.clearMigratedData() + + expect(mockDB.prepare).toHaveBeenCalledWith('DELETE FROM portfolio_images') + expect(mockDB.prepare).toHaveBeenCalledWith('DELETE FROM artists') + expect(mockDB.prepare).toHaveBeenCalledWith('DELETE FROM users WHERE role = "ARTIST"') + }) + + it('should handle clear data errors', async () => { + mockStmt.run.mockRejectedValueOnce(new Error('Clear error')) + + await expect(migrator.clearMigratedData()).rejects.toThrow('Clear error') + }) + }) + + describe('getMigrationStats', () => { + it('should return correct migration statistics', async () => { + mockStmt.first + .mockResolvedValueOnce({ count: 3 }) // total users + .mockResolvedValueOnce({ count: 2 }) // total artists + .mockResolvedValueOnce({ count: 1 }) // total portfolio images + + const stats = await migrator.getMigrationStats() + + expect(stats.totalUsers).toBe(3) + expect(stats.totalArtists).toBe(2) + expect(stats.totalPortfolioImages).toBe(1) + }) + }) +}) diff --git a/__tests__/lib/validations.test.ts b/__tests__/lib/validations.test.ts new file mode 100644 index 000000000..53a2a8e98 --- /dev/null +++ b/__tests__/lib/validations.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect } from 'vitest' +import { createArtistSchema, createAppointmentSchema } from '@/lib/validations' + +describe('Validation Schemas', () => { + describe('createArtistSchema', () => { + it('should validate a valid artist object', () => { + const validArtist = { + name: 'John Doe', + bio: 'Experienced tattoo artist', + specialties: ['Traditional', 'Realism'], + instagramHandle: 'johndoe', + hourlyRate: 150, + isActive: true, + } + + const result = createArtistSchema.safeParse(validArtist) + expect(result.success).toBe(true) + }) + + it('should reject artist with invalid data', () => { + const invalidArtist = { + name: '', // Empty name should fail + bio: 'Bio', + specialties: [], + hourlyRate: -50, // Negative rate should fail + } + + const result = createArtistSchema.safeParse(invalidArtist) + expect(result.success).toBe(false) + }) + + it('should require name field', () => { + const artistWithoutName = { + bio: 'Bio', + specialties: ['Traditional'], + hourlyRate: 150, + } + + const result = createArtistSchema.safeParse(artistWithoutName) + expect(result.success).toBe(false) + }) + }) + + describe('createAppointmentSchema', () => { + it('should validate a valid appointment object', () => { + const validAppointment = { + clientName: 'Jane Smith', + clientEmail: 'jane@example.com', + clientPhone: '+1234567890', + artistId: 'artist-123', + startTime: new Date('2024-12-01T10:00:00Z'), + endTime: new Date('2024-12-01T12:00:00Z'), + description: 'Traditional rose tattoo', + estimatedPrice: 300, + status: 'PENDING' as const, + } + + const result = createAppointmentSchema.safeParse(validAppointment) + expect(result.success).toBe(true) + }) + + it('should reject appointment with invalid email', () => { + const invalidAppointment = { + clientName: 'Jane Smith', + clientEmail: 'invalid-email', // Invalid email format + artistId: 'artist-123', + startTime: new Date('2024-12-01T10:00:00Z'), + endTime: new Date('2024-12-01T12:00:00Z'), + description: 'Tattoo description', + status: 'PENDING' as const, + } + + const result = createAppointmentSchema.safeParse(invalidAppointment) + expect(result.success).toBe(false) + }) + + it('should reject appointment with end time before start time', () => { + const invalidAppointment = { + clientName: 'Jane Smith', + clientEmail: 'jane@example.com', + artistId: 'artist-123', + startTime: new Date('2024-12-01T12:00:00Z'), + endTime: new Date('2024-12-01T10:00:00Z'), // End before start + description: 'Tattoo description', + status: 'PENDING' as const, + } + + const result = createAppointmentSchema.safeParse(invalidAppointment) + expect(result.success).toBe(false) + }) + }) +}) diff --git a/admin_dashboard_implementation_plan.md b/admin_dashboard_implementation_plan.md new file mode 100644 index 000000000..93f3d6818 --- /dev/null +++ b/admin_dashboard_implementation_plan.md @@ -0,0 +1,214 @@ +# Implementation Plan + +## Overview +Implement a comprehensive admin dashboard with full CRUD operations for artist management, Cloudflare R2 file upload system, appointment scheduling interface, and database population from existing artist data. + +This implementation extends the existing United Tattoo Studio platform by building out the admin interface components, integrating Cloudflare R2 for portfolio image uploads, creating a full appointment management system with calendar views, and migrating the current mock artist data into the Cloudflare D1 database. The admin dashboard will provide complete management capabilities for studio operations while maintaining the existing public-facing website functionality. + +## Types +Define comprehensive type system for admin dashboard components and enhanced database operations. + +```typescript +// Admin Dashboard Types +interface AdminDashboardStats { + totalArtists: number + activeArtists: number + totalAppointments: number + pendingAppointments: number + totalUploads: number + recentUploads: number +} + +interface FileUploadProgress { + id: string + filename: string + progress: number + status: 'uploading' | 'processing' | 'complete' | 'error' + url?: string + error?: string +} + +interface CalendarEvent { + id: string + title: string + start: Date + end: Date + artistId: string + clientId: string + status: AppointmentStatus + description?: string +} + +// Enhanced Artist Types +interface ArtistFormData { + name: string + bio: string + specialties: string[] + instagramHandle?: string + hourlyRate?: number + isActive: boolean + email?: string + portfolioImages?: File[] +} + +interface PortfolioImageUpload { + file: File + caption?: string + tags: string[] + orderIndex: number +} + +// File Upload Types +interface R2UploadResponse { + success: boolean + url?: string + key?: string + error?: string +} + +interface BulkUploadResult { + successful: FileUpload[] + failed: { filename: string; error: string }[] + total: number +} +``` + +## Files +Create new admin dashboard pages and components while enhancing existing database and upload functionality. + +**New Files to Create:** +- `app/admin/artists/page.tsx` - Artist management list view +- `app/admin/artists/new/page.tsx` - Create new artist form +- `app/admin/artists/[id]/page.tsx` - Edit artist details +- `app/admin/artists/[id]/portfolio/page.tsx` - Manage artist portfolio +- `app/admin/calendar/page.tsx` - Appointment calendar interface +- `app/admin/uploads/page.tsx` - File upload management +- `app/admin/settings/page.tsx` - Studio settings management +- `components/admin/artist-form.tsx` - Artist creation/editing form +- `components/admin/portfolio-manager.tsx` - Portfolio image management +- `components/admin/file-uploader.tsx` - Cloudflare R2 file upload component +- `components/admin/appointment-calendar.tsx` - Calendar component for appointments +- `components/admin/stats-dashboard.tsx` - Dashboard statistics display +- `components/admin/data-table.tsx` - Reusable data table component +- `lib/r2-upload.ts` - Cloudflare R2 upload utilities +- `lib/data-migration.ts` - Artist data migration utilities +- `hooks/use-file-upload.ts` - File upload hook with progress tracking +- `hooks/use-calendar.ts` - Calendar state management hook + +**Files to Modify:** +- `app/api/artists/route.ts` - Enhance with real database operations +- `app/api/artists/[id]/route.ts` - Add portfolio image management +- `app/api/upload/route.ts` - Implement Cloudflare R2 integration +- `app/api/settings/route.ts` - Add site settings CRUD operations +- `app/api/appointments/route.ts` - Create appointment management API +- `lib/db.ts` - Update database functions to work with runtime environment +- `lib/validations.ts` - Add admin form validation schemas +- `components/admin/sidebar.tsx` - Update navigation for new pages + +## Functions +Implement comprehensive CRUD operations and file management functionality. + +**New Functions:** +- `uploadToR2(file: File, key: string): Promise` in `lib/r2-upload.ts` +- `bulkUploadToR2(files: File[]): Promise` in `lib/r2-upload.ts` +- `migrateArtistData(): Promise` in `lib/data-migration.ts` +- `getArtistStats(): Promise` in `lib/db.ts` +- `createAppointment(data: CreateAppointmentInput): Promise` in `lib/db.ts` +- `getAppointmentsByDateRange(start: Date, end: Date): Promise` in `lib/db.ts` +- `useFileUpload(): FileUploadHook` in `hooks/use-file-upload.ts` +- `useCalendar(): CalendarHook` in `hooks/use-calendar.ts` + +**Modified Functions:** +- Update `getArtists()` in `lib/db.ts` to use actual D1 database +- Enhance `createArtist()` to handle portfolio image uploads +- Modify `updateArtist()` to support portfolio management +- Update API route handlers to use enhanced database functions + +## Classes +Create reusable component classes and utility classes for admin functionality. + +**New Classes:** +- `FileUploadManager` class in `lib/r2-upload.ts` for managing multiple file uploads +- `CalendarManager` class in `lib/calendar.ts` for appointment scheduling logic +- `DataMigrator` class in `lib/data-migration.ts` for database migration operations + +**Component Classes:** +- `ArtistForm` component class with form validation and submission +- `PortfolioManager` component class for drag-and-drop image management +- `FileUploader` component class with progress tracking and error handling +- `AppointmentCalendar` component class with scheduling capabilities + +## Dependencies +Add required packages for enhanced admin functionality. + +**New Dependencies:** +- `@aws-sdk/client-s3` (already installed) - For Cloudflare R2 operations +- `react-big-calendar` (already installed) - For appointment calendar +- `react-dropzone` (already installed) - For file upload interface +- `@tanstack/react-query` (already installed) - For data fetching and caching + +**Configuration Updates:** +- Update `wrangler.toml` with R2 bucket configuration +- Add environment variables for R2 access keys +- Configure Next.js for file upload handling + +## Testing +Implement comprehensive testing for admin dashboard functionality. + +**Test Files to Create:** +- `__tests__/admin/artist-form.test.tsx` - Artist form component tests +- `__tests__/admin/file-upload.test.tsx` - File upload functionality tests +- `__tests__/api/artists.test.ts` - Artist API endpoint tests +- `__tests__/lib/r2-upload.test.ts` - R2 upload utility tests +- `__tests__/lib/data-migration.test.ts` - Data migration tests + +**Testing Strategy:** +- Unit tests for all new utility functions +- Component tests for admin dashboard components +- Integration tests for API endpoints with database operations +- E2E tests for complete admin workflows (create artist, upload portfolio, schedule appointment) + +## Implementation Order +Sequential implementation steps to ensure proper integration and minimal conflicts. + +1. **Database Migration and Population** + - Implement data migration utilities in `lib/data-migration.ts` + - Create migration script to populate D1 database with existing artist data + - Update database functions in `lib/db.ts` to work with runtime environment + - Test database operations with migrated data + +2. **Cloudflare R2 File Upload System** + - Implement R2 upload utilities in `lib/r2-upload.ts` + - Create file upload hook in `hooks/use-file-upload.ts` + - Update upload API route in `app/api/upload/route.ts` + - Create file uploader component in `components/admin/file-uploader.tsx` + +3. **Artist Management Interface** + - Create artist form component in `components/admin/artist-form.tsx` + - Implement artist management pages (`app/admin/artists/`) + - Create portfolio manager component in `components/admin/portfolio-manager.tsx` + - Update artist API routes with enhanced functionality + +4. **Appointment Calendar System** + - Create calendar hook in `hooks/use-calendar.ts` + - Implement appointment calendar component in `components/admin/appointment-calendar.tsx` + - Create appointment API routes in `app/api/appointments/` + - Build calendar page in `app/admin/calendar/page.tsx` + +5. **Admin Dashboard Enhancement** + - Create stats dashboard component in `components/admin/stats-dashboard.tsx` + - Implement settings management in `app/admin/settings/page.tsx` + - Create data table component in `components/admin/data-table.tsx` + - Update main dashboard page with real data integration + +6. **Testing and Validation** + - Implement unit tests for all new functionality + - Create integration tests for API endpoints + - Add E2E tests for admin workflows + - Validate all CRUD operations and file uploads + +7. **Final Integration and Optimization** + - Update sidebar navigation for all new pages + - Implement error handling and loading states + - Add proper TypeScript types throughout + - Optimize performance and add caching where appropriate diff --git a/app/ClientLayout.tsx b/app/ClientLayout.tsx index 926e074e7..b98804fe4 100644 --- a/app/ClientLayout.tsx +++ b/app/ClientLayout.tsx @@ -1,9 +1,13 @@ "use client" import type React from "react" +import { SessionProvider } from "next-auth/react" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { ReactQueryDevtools } from "@tanstack/react-query-devtools" import { SmoothScrollProvider } from "@/components/smooth-scroll-provider" +import { Toaster } from "@/components/ui/sonner" import { useSearchParams } from "next/navigation" -import { Suspense } from "react" +import { Suspense, useState } from "react" import "./globals.css" export default function ClientLayout({ @@ -12,12 +16,39 @@ export default function ClientLayout({ children: React.ReactNode }>) { const searchParams = useSearchParams() + + // Create a new QueryClient instance for each component tree + const [queryClient] = useState( + () => + new QueryClient({ + defaultOptions: { + queries: { + // With SSR, we usually want to set some default staleTime + // above 0 to avoid refetching immediately on the client + staleTime: 60 * 1000, // 1 minute + retry: (failureCount, error: any) => { + // Don't retry on 4xx errors + if (error?.status >= 400 && error?.status < 500) { + return false + } + return failureCount < 3 + }, + }, + }, + }) + ) return ( - <> - Loading...}> - {children} - - + + + Loading...}> + + {children} + + + + + + ) } diff --git a/app/admin/artists/[id]/page.tsx b/app/admin/artists/[id]/page.tsx new file mode 100644 index 000000000..8a96bd407 --- /dev/null +++ b/app/admin/artists/[id]/page.tsx @@ -0,0 +1,76 @@ +"use client" + +import { useState, useEffect } from 'react' +import { useParams } from 'next/navigation' +import { ArtistForm } from '@/components/admin/artist-form' +import { useToast } from '@/hooks/use-toast' +import type { Artist } from '@/types/database' + +export default function EditArtistPage() { + const params = useParams() + const { toast } = useToast() + const [artist, setArtist] = useState(null) + const [loading, setLoading] = useState(true) + + const fetchArtist = async () => { + try { + const response = await fetch(`/api/artists/${params.id}`) + if (!response.ok) throw new Error('Failed to fetch artist') + const data = await response.json() + setArtist(data.artist) + } catch (error) { + console.error('Error fetching artist:', error) + toast({ + title: 'Error', + description: 'Failed to load artist', + variant: 'destructive', + }) + } finally { + setLoading(false) + } + } + + useEffect(() => { + if (params.id) { + fetchArtist() + } + }, [params.id]) + + if (loading) { + return ( +
+
Loading artist...
+
+ ) + } + + if (!artist) { + return ( +
+
Artist not found
+
+ ) + } + + return ( +
+
+

Edit Artist

+

+ Update {artist.name}'s information and portfolio +

+
+ + { + toast({ + title: 'Success', + description: 'Artist updated successfully', + }) + fetchArtist() // Refresh the data + }} + /> +
+ ) +} diff --git a/app/admin/artists/new/page.tsx b/app/admin/artists/new/page.tsx new file mode 100644 index 000000000..cc223600a --- /dev/null +++ b/app/admin/artists/new/page.tsx @@ -0,0 +1,16 @@ +import { ArtistForm } from '@/components/admin/artist-form' + +export default function NewArtistPage() { + return ( +
+
+

Create New Artist

+

+ Add a new artist to your tattoo studio +

+
+ + +
+ ) +} diff --git a/app/admin/artists/page.tsx b/app/admin/artists/page.tsx new file mode 100644 index 000000000..3056567d5 --- /dev/null +++ b/app/admin/artists/page.tsx @@ -0,0 +1,397 @@ +"use client" + +import { useState, useEffect } from 'react' +import { useRouter } from 'next/navigation' +import { Plus, MoreHorizontal, ArrowUpDown, ChevronDown } from 'lucide-react' +import { + ColumnDef, + ColumnFiltersState, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + SortingState, + useReactTable, + VisibilityState, +} from "@tanstack/react-table" + +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Badge } from '@/components/ui/badge' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { useToast } from '@/hooks/use-toast' +import type { Artist } from '@/types/database' + +export default function ArtistsPage() { + const router = useRouter() + const { toast } = useToast() + const [artists, setArtists] = useState([]) + const [loading, setLoading] = useState(true) + const [sorting, setSorting] = useState([]) + const [columnFilters, setColumnFilters] = useState([]) + const [columnVisibility, setColumnVisibility] = useState({}) + const [rowSelection, setRowSelection] = useState({}) + + // Define columns for the data table + const columns: ColumnDef[] = [ + { + accessorKey: "name", + header: ({ column }) => { + return ( + + ) + }, + cell: ({ row }) => ( +
{row.getValue("name")}
+ ), + }, + { + accessorKey: "specialties", + header: "Specialties", + cell: ({ row }) => { + const specialties = row.getValue("specialties") as string + const specialtiesArray = specialties ? JSON.parse(specialties) : [] + return ( +
+ {specialtiesArray.slice(0, 2).map((specialty: string) => ( + + {specialty} + + ))} + {specialtiesArray.length > 2 && ( + + +{specialtiesArray.length - 2} + + )} +
+ ) + }, + }, + { + accessorKey: "hourlyRate", + header: ({ column }) => { + return ( + + ) + }, + cell: ({ row }) => { + const rate = row.getValue("hourlyRate") as number + return rate ? `$${rate}/hr` : 'Not set' + }, + }, + { + accessorKey: "isActive", + header: "Status", + cell: ({ row }) => { + const isActive = row.getValue("isActive") as boolean + return ( + + {isActive ? "Active" : "Inactive"} + + ) + }, + }, + { + accessorKey: "createdAt", + header: "Created", + cell: ({ row }) => { + const date = new Date(row.getValue("createdAt")) + return date.toLocaleDateString() + }, + }, + { + id: "actions", + enableHiding: false, + cell: ({ row }) => { + const artist = row.original + + return ( + + + + + + Actions + router.push(`/admin/artists/${artist.id}`)} + > + Edit artist + + router.push(`/admin/artists/${artist.id}/portfolio`)} + > + Manage portfolio + + + handleToggleStatus(artist)} + className={artist.isActive ? "text-red-600" : "text-green-600"} + > + {artist.isActive ? "Deactivate" : "Activate"} + + + + ) + }, + }, + ] + + const table = useReactTable({ + data: artists, + columns, + onSortingChange: setSorting, + onColumnFiltersChange: setColumnFilters, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + onColumnVisibilityChange: setColumnVisibility, + onRowSelectionChange: setRowSelection, + state: { + sorting, + columnFilters, + columnVisibility, + rowSelection, + }, + }) + + const fetchArtists = async () => { + try { + const response = await fetch('/api/artists') + if (!response.ok) throw new Error('Failed to fetch artists') + const data = await response.json() + setArtists(data.artists || []) + } catch (error) { + console.error('Error fetching artists:', error) + toast({ + title: 'Error', + description: 'Failed to load artists', + variant: 'destructive', + }) + } finally { + setLoading(false) + } + } + + const handleToggleStatus = async (artist: Artist) => { + try { + const response = await fetch(`/api/artists/${artist.id}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + isActive: !artist.isActive, + }), + }) + + if (!response.ok) throw new Error('Failed to update artist') + + toast({ + title: 'Success', + description: `Artist ${artist.isActive ? 'deactivated' : 'activated'} successfully`, + }) + + // Refresh the list + fetchArtists() + } catch (error) { + console.error('Error updating artist:', error) + toast({ + title: 'Error', + description: 'Failed to update artist status', + variant: 'destructive', + }) + } + } + + useEffect(() => { + fetchArtists() + }, []) + + if (loading) { + return ( +
+
Loading artists...
+
+ ) + } + + return ( +
+
+
+

Artists

+

+ Manage your tattoo artists and their information +

+
+ +
+ + + + All Artists + + +
+ {/* Filters and Controls */} +
+
+ + table.getColumn("name")?.setFilterValue(event.target.value) + } + className="max-w-sm" + /> +
+ + + + + + {table + .getAllColumns() + .filter((column) => column.getCanHide()) + .map((column) => { + return ( + + column.toggleVisibility(!!value) + } + > + {column.id} + + ) + })} + + +
+ + {/* Data Table */} +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ) + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + router.push(`/admin/artists/${row.original.id}`)} + > + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + )) + ) : ( + + + No artists found. + + + )} + +
+
+ + {/* Pagination */} +
+
+ {table.getFilteredSelectedRowModel().rows.length} of{" "} + {table.getFilteredRowModel().rows.length} row(s) selected. +
+
+ + +
+
+
+
+
+
+ ) +} diff --git a/app/admin/calendar/page.tsx b/app/admin/calendar/page.tsx new file mode 100644 index 000000000..d950cdda8 --- /dev/null +++ b/app/admin/calendar/page.tsx @@ -0,0 +1,458 @@ +'use client' + +import { useState, useEffect } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { AppointmentCalendar } from '@/components/admin/appointment-calendar' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form' +import { Input } from '@/components/ui/input' +import { Textarea } from '@/components/ui/textarea' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Badge } from '@/components/ui/badge' +import { CalendarIcon, Plus, Users, Clock, CheckCircle, XCircle } from 'lucide-react' +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { z } from 'zod' +import { toast } from 'sonner' +import moment from 'moment' + +const appointmentSchema = z.object({ + artistId: z.string().min(1, 'Artist is required'), + clientName: z.string().min(1, 'Client name is required'), + clientEmail: z.string().email('Valid email is required'), + title: z.string().min(1, 'Title is required'), + description: z.string().optional(), + startTime: z.string().min(1, 'Start time is required'), + endTime: z.string().min(1, 'End time is required'), + depositAmount: z.number().optional(), + totalAmount: z.number().optional(), + notes: z.string().optional(), +}) + +type AppointmentFormData = z.infer + +export default function CalendarPage() { + const [isNewAppointmentOpen, setIsNewAppointmentOpen] = useState(false) + const [selectedSlot, setSelectedSlot] = useState<{ start: Date; end: Date } | null>(null) + const queryClient = useQueryClient() + + const form = useForm({ + resolver: zodResolver(appointmentSchema), + defaultValues: { + artistId: '', + clientName: '', + clientEmail: '', + title: '', + description: '', + startTime: '', + endTime: '', + depositAmount: undefined, + totalAmount: undefined, + notes: '', + }, + }) + + // Fetch appointments + const { data: appointmentsData, isLoading: appointmentsLoading } = useQuery({ + queryKey: ['appointments'], + queryFn: async () => { + const response = await fetch('/api/appointments') + if (!response.ok) throw new Error('Failed to fetch appointments') + return response.json() + }, + }) + + // Fetch artists + const { data: artistsData, isLoading: artistsLoading } = useQuery({ + queryKey: ['artists'], + queryFn: async () => { + const response = await fetch('/api/artists') + if (!response.ok) throw new Error('Failed to fetch artists') + return response.json() + }, + }) + + // Create appointment mutation + const createAppointmentMutation = useMutation({ + mutationFn: async (data: AppointmentFormData) => { + // First, create or find the client user + const clientResponse = await fetch('/api/users', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: data.clientName, + email: data.clientEmail, + role: 'CLIENT', + }), + }) + + let clientId + if (clientResponse.ok) { + const client = await clientResponse.json() + clientId = client.user.id + } else { + // If user already exists, try to find them + const existingUserResponse = await fetch(`/api/users?email=${encodeURIComponent(data.clientEmail)}`) + if (existingUserResponse.ok) { + const existingUser = await existingUserResponse.json() + clientId = existingUser.user.id + } else { + throw new Error('Failed to create or find client') + } + } + + // Create the appointment + const appointmentResponse = await fetch('/api/appointments', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + ...data, + clientId, + startTime: new Date(data.startTime).toISOString(), + endTime: new Date(data.endTime).toISOString(), + }), + }) + + if (!appointmentResponse.ok) { + const error = await appointmentResponse.json() + throw new Error(error.error || 'Failed to create appointment') + } + + return appointmentResponse.json() + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['appointments'] }) + setIsNewAppointmentOpen(false) + form.reset() + toast.success('Appointment created successfully') + }, + onError: (error: Error) => { + toast.error(error.message) + }, + }) + + // Update appointment mutation + const updateAppointmentMutation = useMutation({ + mutationFn: async ({ id, updates }: { id: string; updates: any }) => { + const response = await fetch('/api/appointments', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id, ...updates }), + }) + + if (!response.ok) { + const error = await response.json() + throw new Error(error.error || 'Failed to update appointment') + } + + return response.json() + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['appointments'] }) + toast.success('Appointment updated successfully') + }, + onError: (error: Error) => { + toast.error(error.message) + }, + }) + + // Handle slot selection for new appointment + const handleSlotSelect = (slotInfo: { start: Date; end: Date; slots: Date[] }) => { + setSelectedSlot({ start: slotInfo.start, end: slotInfo.end }) + form.setValue('startTime', moment(slotInfo.start).format('YYYY-MM-DDTHH:mm')) + form.setValue('endTime', moment(slotInfo.end).format('YYYY-MM-DDTHH:mm')) + setIsNewAppointmentOpen(true) + } + + // Handle event update + const handleEventUpdate = (eventId: string, updates: any) => { + updateAppointmentMutation.mutate({ id: eventId, updates }) + } + + const onSubmit = (data: AppointmentFormData) => { + createAppointmentMutation.mutate(data) + } + + const appointments = appointmentsData?.appointments || [] + const artists = artistsData?.artists || [] + + // Calculate stats + const stats = { + total: appointments.length, + pending: appointments.filter((apt: any) => apt.status === 'PENDING').length, + confirmed: appointments.filter((apt: any) => apt.status === 'CONFIRMED').length, + completed: appointments.filter((apt: any) => apt.status === 'COMPLETED').length, + } + + if (appointmentsLoading || artistsLoading) { + return ( +
+
+
+

Loading calendar...

+
+
+ ) + } + + return ( +
+ {/* Header */} +
+
+

Appointment Calendar

+

Manage studio appointments and scheduling

+
+ + + + + + + + Create New Appointment + + +
+ + ( + + Artist + + + + )} + /> + +
+ ( + + Client Name + + + + + + )} + /> + + ( + + Client Email + + + + + + )} + /> +
+ + ( + + Appointment Title + + + + + + )} + /> + + ( + + Description + +