swallowkit 1.0.0-beta.5 → 1.0.0-beta.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/LICENSE +21 -21
  2. package/README.ja.md +251 -242
  3. package/README.md +252 -243
  4. package/dist/__tests__/fixtures.d.ts +14 -0
  5. package/dist/__tests__/fixtures.d.ts.map +1 -0
  6. package/dist/__tests__/fixtures.js +85 -0
  7. package/dist/__tests__/fixtures.js.map +1 -0
  8. package/dist/cli/commands/create-model.js +14 -14
  9. package/dist/cli/commands/dev.d.ts +8 -0
  10. package/dist/cli/commands/dev.d.ts.map +1 -1
  11. package/dist/cli/commands/dev.js +238 -30
  12. package/dist/cli/commands/dev.js.map +1 -1
  13. package/dist/cli/commands/init.d.ts +5 -0
  14. package/dist/cli/commands/init.d.ts.map +1 -1
  15. package/dist/cli/commands/init.js +2507 -1664
  16. package/dist/cli/commands/init.js.map +1 -1
  17. package/dist/cli/commands/scaffold.d.ts +3 -0
  18. package/dist/cli/commands/scaffold.d.ts.map +1 -1
  19. package/dist/cli/commands/scaffold.js +281 -117
  20. package/dist/cli/commands/scaffold.js.map +1 -1
  21. package/dist/cli/index.js +2 -0
  22. package/dist/cli/index.js.map +1 -1
  23. package/dist/core/config.d.ts +2 -1
  24. package/dist/core/config.d.ts.map +1 -1
  25. package/dist/core/config.js +28 -0
  26. package/dist/core/config.js.map +1 -1
  27. package/dist/core/scaffold/functions-generator.d.ts +5 -0
  28. package/dist/core/scaffold/functions-generator.d.ts.map +1 -1
  29. package/dist/core/scaffold/functions-generator.js +649 -218
  30. package/dist/core/scaffold/functions-generator.js.map +1 -1
  31. package/dist/core/scaffold/model-parser.d.ts +1 -1
  32. package/dist/core/scaffold/model-parser.js +99 -99
  33. package/dist/core/scaffold/nextjs-generator.js +181 -181
  34. package/dist/core/scaffold/openapi-generator.d.ts +3 -0
  35. package/dist/core/scaffold/openapi-generator.d.ts.map +1 -0
  36. package/dist/core/scaffold/openapi-generator.js +190 -0
  37. package/dist/core/scaffold/openapi-generator.js.map +1 -0
  38. package/dist/core/scaffold/ui-generator.js +656 -656
  39. package/dist/database/base-model.d.ts +3 -3
  40. package/dist/database/base-model.js +3 -3
  41. package/dist/index.d.ts +2 -2
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +2 -1
  44. package/dist/index.js.map +1 -1
  45. package/dist/types/index.d.ts +4 -0
  46. package/dist/types/index.d.ts.map +1 -1
  47. package/dist/utils/package-manager.d.ts +2 -1
  48. package/dist/utils/package-manager.d.ts.map +1 -1
  49. package/dist/utils/package-manager.js +14 -10
  50. package/dist/utils/package-manager.js.map +1 -1
  51. package/package.json +81 -74
  52. package/src/__tests__/__snapshots__/functions-generator.test.ts.snap +445 -0
  53. package/src/__tests__/__snapshots__/nextjs-generator.test.ts.snap +194 -0
  54. package/src/__tests__/__snapshots__/ui-generator.test.ts.snap +524 -0
  55. package/src/__tests__/config.test.ts +122 -0
  56. package/src/__tests__/dev.test.ts +42 -0
  57. package/src/__tests__/fixtures.ts +83 -0
  58. package/src/__tests__/functions-generator.test.ts +101 -0
  59. package/src/__tests__/init.test.ts +59 -0
  60. package/src/__tests__/nextjs-generator.test.ts +97 -0
  61. package/src/__tests__/openapi-generator.test.ts +43 -0
  62. package/src/__tests__/package-manager.test.ts +189 -0
  63. package/src/__tests__/scaffold.test.ts +39 -0
  64. package/src/__tests__/string-utils.test.ts +75 -0
  65. package/src/__tests__/ui-generator.test.ts +144 -0
  66. package/src/cli/commands/create-model.ts +141 -0
  67. package/src/cli/commands/dev.ts +794 -0
  68. package/src/cli/commands/index.ts +8 -0
  69. package/src/cli/commands/init.ts +3363 -0
  70. package/src/cli/commands/provision.ts +193 -0
  71. package/src/cli/commands/scaffold.ts +786 -0
  72. package/src/cli/index.ts +73 -0
  73. package/src/core/config.ts +244 -0
  74. package/src/core/scaffold/functions-generator.ts +674 -0
  75. package/src/core/scaffold/model-parser.ts +627 -0
  76. package/src/core/scaffold/nextjs-generator.ts +217 -0
  77. package/src/core/scaffold/openapi-generator.ts +212 -0
  78. package/src/core/scaffold/ui-generator.ts +945 -0
  79. package/src/database/base-model.ts +184 -0
  80. package/src/database/client.ts +140 -0
  81. package/src/database/repository.ts +104 -0
  82. package/src/database/runtime-check.ts +25 -0
  83. package/src/index.ts +27 -0
  84. package/src/types/index.ts +45 -0
  85. package/src/utils/package-manager.ts +229 -0
  86. package/dist/cli/commands/build.d.ts +0 -6
  87. package/dist/cli/commands/build.d.ts.map +0 -1
  88. package/dist/cli/commands/build.js +0 -177
  89. package/dist/cli/commands/build.js.map +0 -1
  90. package/dist/cli/commands/deploy.d.ts +0 -3
  91. package/dist/cli/commands/deploy.d.ts.map +0 -1
  92. package/dist/cli/commands/deploy.js +0 -147
  93. package/dist/cli/commands/deploy.js.map +0 -1
  94. package/dist/cli/commands/setup.d.ts +0 -6
  95. package/dist/cli/commands/setup.d.ts.map +0 -1
  96. package/dist/cli/commands/setup.js +0 -254
  97. package/dist/cli/commands/setup.js.map +0 -1
@@ -0,0 +1,945 @@
1
+ /**
2
+ * Next.js UI コンポーネント生成
3
+ * CRUD 画面(一覧、詳細、新規作成、編集)を生成する
4
+ */
5
+
6
+ import { ModelInfo, FieldInfo, toCamelCase, toKebabCase, toPascalCase } from "./model-parser";
7
+
8
+ /**
9
+ * 一覧画面を生成
10
+ */
11
+ export function generateListPage(model: ModelInfo, sharedPackageName: string): string {
12
+ const modelName = model.name;
13
+ const modelCamel = toCamelCase(modelName);
14
+ const modelKebab = toKebabCase(modelName);
15
+
16
+ // フィールドから表示するカラムを抽出(id以外の最初の3つ)
17
+ const displayFields = model.fields
18
+ .filter(f => f.name !== 'id')
19
+ .slice(0, 3);
20
+
21
+ // 外部キーフィールドを検出
22
+ const foreignKeyFields = displayFields.filter(f => f.isForeignKey);
23
+ const hasForeignKeys = foreignKeyFields.length > 0;
24
+
25
+ // ネストスキーマフィールドを検出
26
+ const nestedFields = displayFields.filter(f => f.isNestedSchema);
27
+ const hasNestedSchemas = nestedFields.length > 0;
28
+
29
+ // 外部キー用のstate定義を生成
30
+ const foreignKeyStates = foreignKeyFields.map(f => {
31
+ const refModel = f.referencedModel!;
32
+ const refModelCamel = toCamelCase(refModel);
33
+ return ` const [${refModelCamel}Map, set${refModel}Map] = useState<Record<string, string>>({});`;
34
+ }).join('\n');
35
+
36
+ // 外部キーデータのフェッチロジックを生成
37
+ const foreignKeyFetches = foreignKeyFields.map(f => {
38
+ const refModel = f.referencedModel!;
39
+ const refModelCamel = toCamelCase(refModel);
40
+ return ` fetch('/api/${refModelCamel}')
41
+ .then(res => res.json())
42
+ .then((data: any[]) => {
43
+ const map: Record<string, string> = {};
44
+ data.forEach(item => {
45
+ // name または title フィールドを表示用文字列として使用
46
+ map[item.id] = item.name || item.title || item.id;
47
+ });
48
+ set${refModel}Map(map);
49
+ })
50
+ .catch(err => console.error('Failed to fetch ${refModel}s:', err));`;
51
+ }).join('\n');
52
+
53
+ const schemaName = model.schemaName;
54
+ // schemaNameとmodelNameが同じ場合はimportエイリアスで名前衝突を回避
55
+ const needsAlias = schemaName === modelName;
56
+ const localSchemaName = needsAlias ? `${toCamelCase(modelName)}Schema` : schemaName;
57
+ const schemaImportLine = needsAlias
58
+ ? `import { ${schemaName} as ${localSchemaName} } from '${sharedPackageName}';`
59
+ : `import { ${schemaName} } from '${sharedPackageName}';`;
60
+
61
+ return `'use client';
62
+
63
+ import { useEffect, useState } from 'react';
64
+ import Link from 'next/link';
65
+ import { z } from 'zod/v4';
66
+ ${schemaImportLine}
67
+
68
+ type ${modelName} = z.infer<typeof ${localSchemaName}>;
69
+
70
+ export default function ${modelName}ListPage() {
71
+ const [${modelCamel}s, set${modelName}s] = useState<${modelName}[]>([]);
72
+ const [loading, setLoading] = useState(true);
73
+ const [error, setError] = useState<string | null>(null);
74
+ ${hasForeignKeys ? foreignKeyStates : ''}
75
+
76
+ useEffect(() => {
77
+ fetch('/api/${modelCamel}')
78
+ .then((res) => {
79
+ if (!res.ok) throw new Error('Failed to fetch ${modelCamel}s');
80
+ return res.json();
81
+ })
82
+ .then((data) => {
83
+ set${modelName}s(data);
84
+ setLoading(false);
85
+ })
86
+ .catch((err) => {
87
+ setError(err.message);
88
+ setLoading(false);
89
+ });
90
+ ${hasForeignKeys ? '\n // Fetch foreign key reference data' : ''}
91
+ ${hasForeignKeys ? foreignKeyFetches : ''}
92
+ }, []);
93
+
94
+ const handleDelete = async (id: string) => {
95
+ if (!confirm('Are you sure you want to delete this item?')) return;
96
+
97
+ try {
98
+ const res = await fetch(\`/api/${modelCamel}/\${id}\`, {
99
+ method: 'DELETE',
100
+ });
101
+
102
+ if (!res.ok) throw new Error('Failed to delete ${modelCamel}');
103
+
104
+ set${modelName}s(${modelCamel}s.filter((item) => item.id !== id));
105
+ } catch (err: any) {
106
+ alert(\`Error: \${err.message}\`);
107
+ }
108
+ };
109
+
110
+ if (loading) {
111
+ return (
112
+ <div className="flex items-center justify-center min-h-screen">
113
+ <div className="text-lg text-gray-900 dark:text-gray-100">Loading...</div>
114
+ </div>
115
+ );
116
+ }
117
+
118
+ if (error) {
119
+ return (
120
+ <div className="flex items-center justify-center min-h-screen">
121
+ <div className="text-red-600 dark:text-red-400">Error: {error}</div>
122
+ </div>
123
+ );
124
+ }
125
+
126
+ return (
127
+ <div className="container mx-auto px-4 py-8">
128
+ <div className="mb-4">
129
+ <Link
130
+ href="/"
131
+ className="text-blue-600 dark:text-blue-400 hover:text-blue-900 dark:hover:text-blue-300 text-sm"
132
+ >
133
+ ← Home
134
+ </Link>
135
+ </div>
136
+ <div className="flex justify-between items-center mb-6">
137
+ <h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">${modelName}</h1>
138
+ <Link
139
+ href="/${modelKebab}/new"
140
+ className="bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 text-white px-4 py-2 rounded"
141
+ >
142
+ Create New
143
+ </Link>
144
+ </div>
145
+
146
+ {${modelCamel}s.length === 0 ? (
147
+ <div className="text-center py-12 text-gray-500 dark:text-gray-400">
148
+ No ${modelCamel}s found. Create your first one!
149
+ </div>
150
+ ) : (
151
+ <div className="bg-white dark:bg-gray-800 shadow-md rounded-lg overflow-hidden">
152
+ <table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
153
+ <thead className="bg-gray-50 dark:bg-gray-900">
154
+ <tr>
155
+ ${displayFields.map(f => {
156
+ const headerLabel = f.isNestedSchema && f.nestedModelName
157
+ ? f.nestedModelName
158
+ : (f.isForeignKey && f.referencedModel ? f.referencedModel : f.name);
159
+ return ` <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
160
+ ${headerLabel}
161
+ </th>`;
162
+ }).join('\n')}
163
+ <th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
164
+ Actions
165
+ </th>
166
+ </tr>
167
+ </thead>
168
+ <tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
169
+ {${modelCamel}s.map((item) => (
170
+ <tr key={item.id} className="hover:bg-gray-50 dark:hover:bg-gray-700">
171
+ ${displayFields.map(f => {
172
+ if (f.isNestedSchema && f.nestedModelName) {
173
+ const displayField = f.nestedDisplayField || 'name';
174
+ if (f.isArray) {
175
+ return ` <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
176
+ {Array.isArray(item.${f.name}) ? item.${f.name}.map((ref: any) => ref?.${displayField} || '-').join(', ') : '-'}
177
+ </td>`;
178
+ } else {
179
+ return ` <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
180
+ {item.${f.name}?.${displayField} || '-'}
181
+ </td>`;
182
+ }
183
+ } else if (f.isForeignKey && f.referencedModel) {
184
+ const refModel = f.referencedModel;
185
+ const refModelCamel = toCamelCase(refModel);
186
+ return ` <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
187
+ {${refModelCamel}Map[item.${f.name}] || item.${f.name}}
188
+ </td>`;
189
+ } else {
190
+ return ` <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
191
+ {String(item.${f.name})}
192
+ </td>`;
193
+ }
194
+ }).join('\n')}
195
+ <td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
196
+ <Link
197
+ href={\`/${modelKebab}/\${item.id}\`}
198
+ className="text-blue-600 dark:text-blue-400 hover:text-blue-900 dark:hover:text-blue-300 mr-4"
199
+ >
200
+ View
201
+ </Link>
202
+ <Link
203
+ href={\`/${modelKebab}/\${item.id}/edit\`}
204
+ className="text-green-600 dark:text-green-400 hover:text-green-900 dark:hover:text-green-300 mr-4"
205
+ >
206
+ Edit
207
+ </Link>
208
+ <button
209
+ onClick={() => handleDelete(item.id)}
210
+ className="text-red-600 dark:text-red-400 hover:text-red-900 dark:hover:text-red-300"
211
+ >
212
+ Delete
213
+ </button>
214
+ </td>
215
+ </tr>
216
+ ))}
217
+ </tbody>
218
+ </table>
219
+ </div>
220
+ )}
221
+ </div>
222
+ );
223
+ }
224
+ `;
225
+ }
226
+
227
+ /**
228
+ * 詳細画面を生成
229
+ */
230
+ export function generateDetailPage(model: ModelInfo, sharedPackageName: string): string {
231
+ const modelName = model.name;
232
+ const modelCamel = toCamelCase(modelName);
233
+ const modelKebab = toKebabCase(modelName);
234
+
235
+ // 外部キーフィールドを検出
236
+ const foreignKeyFields = model.fields.filter(f => f.isForeignKey);
237
+ const hasForeignKeys = foreignKeyFields.length > 0;
238
+
239
+ // 外部キー用のstate定義を生成
240
+ const foreignKeyStates = foreignKeyFields.map(f => {
241
+ const refModel = f.referencedModel!;
242
+ const refModelCamel = toCamelCase(refModel);
243
+ return ` const [${refModelCamel}Map, set${refModel}Map] = useState<Record<string, string>>({});`;
244
+ }).join('\n');
245
+
246
+ // 外部キーデータのフェッチロジックを生成
247
+ const foreignKeyFetches = foreignKeyFields.map(f => {
248
+ const refModel = f.referencedModel!;
249
+ const refModelCamel = toCamelCase(refModel);
250
+ return ` fetch('/api/${refModelCamel}')
251
+ .then(res => res.json())
252
+ .then((data: any[]) => {
253
+ const map: Record<string, string> = {};
254
+ data.forEach(item => {
255
+ map[item.id] = item.name || item.title || item.id;
256
+ });
257
+ set${refModel}Map(map);
258
+ })
259
+ .catch(err => console.error('Failed to fetch ${refModel}s:', err));`;
260
+ }).join('\n');
261
+
262
+ const schemaName = model.schemaName;
263
+ // Zod公式パターン対応: schemaNameとmodelNameが同じ場合はimportエイリアスで名前衝突を回避
264
+ const needsAlias = schemaName === modelName;
265
+ const localSchemaName = needsAlias ? `${toCamelCase(modelName)}Schema` : schemaName;
266
+ const schemaImportLine = needsAlias
267
+ ? `import { ${schemaName} as ${localSchemaName} } from '${sharedPackageName}';`
268
+ : `import { ${schemaName} } from '${sharedPackageName}';`;
269
+
270
+ return `'use client';
271
+
272
+ import { useEffect, useState } from 'react';
273
+ import { useParams, useRouter } from 'next/navigation';
274
+ import Link from 'next/link';
275
+ import { z } from 'zod/v4';
276
+ ${schemaImportLine}
277
+
278
+ type ${modelName} = z.infer<typeof ${localSchemaName}>;
279
+
280
+ export default function ${modelName}DetailPage() {
281
+ const params = useParams();
282
+ const router = useRouter();
283
+ const [${modelCamel}, set${modelName}] = useState<${modelName} | null>(null);
284
+ const [loading, setLoading] = useState(true);
285
+ const [error, setError] = useState<string | null>(null);
286
+ ${hasForeignKeys ? foreignKeyStates : ''}
287
+
288
+ useEffect(() => {
289
+ const id = params?.id as string;
290
+ if (!id) return;
291
+
292
+ fetch(\`/api/${modelCamel}/\${id}\`)
293
+ .then((res) => {
294
+ if (!res.ok) throw new Error('Failed to fetch ${modelCamel}');
295
+ return res.json();
296
+ })
297
+ .then((data) => {
298
+ set${modelName}(data);
299
+ setLoading(false);
300
+ })
301
+ .catch((err) => {
302
+ setError(err.message);
303
+ setLoading(false);
304
+ });
305
+ ${hasForeignKeys ? '\n // Fetch foreign key reference data' : ''}
306
+ ${hasForeignKeys ? foreignKeyFetches : ''}
307
+ }, [params]);
308
+
309
+ const handleDelete = async () => {
310
+ if (!confirm('Are you sure you want to delete this item?')) return;
311
+
312
+ try {
313
+ const res = await fetch(\`/api/${modelCamel}/\${params?.id}\`, {
314
+ method: 'DELETE',
315
+ });
316
+
317
+ if (!res.ok) throw new Error('Failed to delete ${modelCamel}');
318
+
319
+ router.push('/${modelKebab}');
320
+ } catch (err: any) {
321
+ alert(\`Error: \${err.message}\`);
322
+ }
323
+ };
324
+
325
+ if (loading) {
326
+ return (
327
+ <div className="flex items-center justify-center min-h-screen">
328
+ <div className="text-lg text-gray-900 dark:text-gray-100">Loading...</div>
329
+ </div>
330
+ );
331
+ }
332
+
333
+ if (error || !${modelCamel}) {
334
+ return (
335
+ <div className="flex items-center justify-center min-h-screen">
336
+ <div className="text-red-600 dark:text-red-400">Error: {error || '${modelName} not found'}</div>
337
+ </div>
338
+ );
339
+ }
340
+
341
+ return (
342
+ <div className="container mx-auto px-4 py-8">
343
+ <div className="max-w-2xl mx-auto">
344
+ <div className="flex justify-between items-center mb-6">
345
+ <h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">${modelName} Details</h1>
346
+ <div className="space-x-2">
347
+ <Link
348
+ href={\`/${modelKebab}/\${${modelCamel}.id}/edit\`}
349
+ className="inline-flex items-center bg-green-600 hover:bg-green-700 dark:bg-green-500 dark:hover:bg-green-600 text-white px-4 py-2 rounded"
350
+ >
351
+ Edit
352
+ </Link>
353
+ <button
354
+ onClick={handleDelete}
355
+ className="inline-flex items-center bg-red-600 hover:bg-red-700 dark:bg-red-500 dark:hover:bg-red-600 text-white px-4 py-2 rounded"
356
+ >
357
+ Delete
358
+ </button>
359
+ </div>
360
+ </div>
361
+
362
+ <div className="bg-white dark:bg-gray-800 shadow-md rounded-lg p-6">
363
+ <dl className="space-y-4">
364
+ ${model.fields.map(f => {
365
+ if (f.isNestedSchema && f.nestedModelName) {
366
+ const displayField = f.nestedDisplayField || 'name';
367
+ const label = f.nestedModelName;
368
+ if (f.isArray) {
369
+ return ` <div>
370
+ <dt className="text-sm font-medium text-gray-500 dark:text-gray-400">${label}</dt>
371
+ <dd className="mt-1 text-sm text-gray-900 dark:text-gray-100">{Array.isArray(${modelCamel}.${f.name}) ? ${modelCamel}.${f.name}.map((ref: any) => ref?.${displayField} || '-').join(', ') : '-'}</dd>
372
+ </div>`;
373
+ } else {
374
+ return ` <div>
375
+ <dt className="text-sm font-medium text-gray-500 dark:text-gray-400">${label}</dt>
376
+ <dd className="mt-1 text-sm text-gray-900 dark:text-gray-100">{${modelCamel}.${f.name}?.${displayField} || '-'}</dd>
377
+ </div>`;
378
+ }
379
+ } else if (f.isForeignKey && f.referencedModel) {
380
+ const refModel = f.referencedModel;
381
+ const refModelCamel = toCamelCase(refModel);
382
+ return ` <div>
383
+ <dt className="text-sm font-medium text-gray-500 dark:text-gray-400">${refModel}</dt>
384
+ <dd className="mt-1 text-sm text-gray-900 dark:text-gray-100">{${refModelCamel}Map[${modelCamel}.${f.name}] || ${modelCamel}.${f.name}}</dd>
385
+ </div>`;
386
+ } else {
387
+ return ` <div>
388
+ <dt className="text-sm font-medium text-gray-500 dark:text-gray-400">${f.name}</dt>
389
+ <dd className="mt-1 text-sm text-gray-900 dark:text-gray-100">{String(${modelCamel}.${f.name})}</dd>
390
+ </div>`;
391
+ }
392
+ }).join('\n')}
393
+ </dl>
394
+ </div>
395
+
396
+ <div className="mt-6">
397
+ <Link
398
+ href="/${modelKebab}"
399
+ className="text-blue-600 dark:text-blue-400 hover:text-blue-900 dark:hover:text-blue-300"
400
+ >
401
+ &larr; Back to list
402
+ </Link>
403
+ </div>
404
+ </div>
405
+ </div>
406
+ );
407
+ }
408
+ `;
409
+ }
410
+
411
+ /**
412
+ * フォームコンポーネントを生成
413
+ */
414
+ export function generateFormComponent(model: ModelInfo, sharedPackageName: string): string {
415
+ const modelName = model.name;
416
+ const modelCamel = toCamelCase(modelName);
417
+ const schemaName = model.schemaName;
418
+ // Zod公式パターン対応: schemaNameとmodelNameが同じ場合はimportエイリアスで名前衝突を回避
419
+ const needsAlias = schemaName === modelName;
420
+ const localSchemaName = needsAlias ? `${toCamelCase(modelName)}Schema` : schemaName;
421
+ const schemaImportLine = needsAlias
422
+ ? `import { ${schemaName} as ${localSchemaName} } from '${sharedPackageName}';`
423
+ : `import { ${schemaName} } from '${sharedPackageName}';`;
424
+
425
+ // id, createdAt, updatedAt 以外のフィールド
426
+ const formFields = model.fields.filter(f =>
427
+ f.name !== 'id' &&
428
+ f.name !== 'createdAt' &&
429
+ f.name !== 'updatedAt'
430
+ );
431
+ // 外部キーフィールドを抽出
432
+ const foreignKeyFields = formFields.filter(f => f.isForeignKey);
433
+ const hasForignKeys = foreignKeyFields.length > 0;
434
+
435
+ // ネストスキーマフィールドを抽出
436
+ const nestedSchemaFields = formFields.filter(f => f.isNestedSchema);
437
+ const hasNestedSchemas = nestedSchemaFields.length > 0;
438
+
439
+ // useEffect が必要かどうか
440
+ const needsUseEffect = hasForignKeys || hasNestedSchemas;
441
+
442
+ // ネストスキーマ用のstate定義を生成
443
+ const nestedSchemaStates = nestedSchemaFields.map(f => {
444
+ const refModelCamel = toCamelCase(f.nestedModelName!);
445
+ const refModelPascal = f.nestedModelName!;
446
+ return ` const [${refModelCamel}Options, set${refModelPascal}Options] = useState<Array<{ id: string; name: string }>>([]);`;
447
+ }).join('\n');
448
+
449
+ // ネストスキーマデータのフェッチロジック
450
+ const nestedSchemaFetches = nestedSchemaFields.map(f => {
451
+ const refModelCamel = toCamelCase(f.nestedModelName!);
452
+ const refModelPascal = f.nestedModelName!;
453
+ const displayField = f.nestedDisplayField || 'name';
454
+ return ` // ${refModelPascal} の一覧を取得
455
+ fetch('/api/${refModelCamel}')
456
+ .then(res => res.json())
457
+ .then(data => set${refModelPascal}Options(data.map((item: any) => ({ id: item.id, name: item.${displayField} || item.name || item.title || item.id }))))
458
+ .catch(err => console.error('Failed to load ${refModelPascal} options:', err));`;
459
+ }).join('\n');
460
+
461
+ return `'use client';
462
+
463
+ import { useState, FormEvent${needsUseEffect ? ', useEffect' : ''} } from 'react';
464
+ import { useRouter } from 'next/navigation';
465
+ import { z } from 'zod/v4';
466
+ ${schemaImportLine}
467
+
468
+ // Input schema: SwallowKit-managed fields (id, createdAt, updatedAt) are optional
469
+ // These fields are ignored by the backend and auto-managed
470
+ const ${modelName}InputSchema = ${localSchemaName}.partial({ id: true, createdAt: true, updatedAt: true });
471
+
472
+ type ${modelName} = z.infer<typeof ${localSchemaName}>;
473
+
474
+ interface ${modelName}FormProps {
475
+ initialData?: ${modelName};
476
+ isEdit?: boolean;
477
+ }
478
+
479
+ export default function ${modelName}Form({ initialData, isEdit = false }: ${modelName}FormProps) {
480
+ const router = useRouter();
481
+ const [loading, setLoading] = useState(false);
482
+ const [errors, setErrors] = useState<Record<string, string>>({});
483
+ ${hasForignKeys ? foreignKeyFields.map(f => ` const [${toCamelCase(f.referencedModel!)}Options, set${f.referencedModel}Options] = useState<Array<{ id: string; name: string }>>([]);`).join('\n') : ''}
484
+ ${hasNestedSchemas ? nestedSchemaStates : ''}
485
+
486
+ const [formData, setFormData] = useState({
487
+ ${formFields.map(f => {
488
+ // ネストスキーマの場合は参照ID(単一: string, 配列: string[])を管理
489
+ if (f.isNestedSchema) {
490
+ if (f.isArray) {
491
+ return ` ${f.name}Ids: initialData?.${f.name} ? (Array.isArray(initialData.${f.name}) ? initialData.${f.name}.map((item: any) => item.id) : []) : [] as string[],`;
492
+ } else {
493
+ return ` ${f.name}Id: initialData?.${f.name}?.id ?? '',`;
494
+ }
495
+ }
496
+
497
+ let defaultValue = "''";
498
+ if (f.isArray) {
499
+ // Array の場合は空文字列(カンマ区切りで入力するため)
500
+ defaultValue = "''";
501
+ } else if (f.type === 'number') {
502
+ // Number の場合も空文字列を許容(オプショナル対応)
503
+ defaultValue = "''";
504
+ } else if (f.type === 'boolean') {
505
+ defaultValue = 'false';
506
+ } else {
507
+ defaultValue = "''";
508
+ }
509
+
510
+ // initialData からの取得も型に応じて変換
511
+ if (f.isArray) {
512
+ return ` ${f.name}: initialData?.${f.name} ? (Array.isArray(initialData.${f.name}) ? initialData.${f.name}.join(', ') : '') : ${defaultValue},`;
513
+ } else if (f.type === 'number') {
514
+ return ` ${f.name}: initialData?.${f.name} !== undefined ? String(initialData.${f.name}) : ${defaultValue},`;
515
+ }
516
+ return ` ${f.name}: initialData?.${f.name} ?? ${defaultValue},`;
517
+ }).join('\n')}
518
+ });
519
+ ${needsUseEffect ? `
520
+ // 参照データの選択肢を取得
521
+ useEffect(() => {
522
+ ${hasForignKeys ? foreignKeyFields.map(f => ` // ${f.referencedModel} の一覧を取得
523
+ fetch('/api/${toKebabCase(f.referencedModel!)}')
524
+ .then(res => res.json())
525
+ .then(data => set${f.referencedModel}Options(data.map((item: any) => ({ id: item.id, name: item.name || item.title || item.id }))))
526
+ .catch(err => console.error('Failed to load ${f.referencedModel} options:', err));`).join('\n') : ''}
527
+ ${hasNestedSchemas ? nestedSchemaFetches : ''}
528
+ }, []);
529
+ ` : ''}
530
+ const handleSubmit = async (e: FormEvent) => {
531
+ e.preventDefault();
532
+ setLoading(true);
533
+ setErrors({});
534
+
535
+ try {
536
+ // Array フィールドをカンマ区切りから配列に変換
537
+ const submitData: any = { ...formData };
538
+ ${formFields.filter(f => f.isArray && !f.isNestedSchema).map(f => ` if (typeof submitData.${f.name} === 'string') {
539
+ submitData.${f.name} = submitData.${f.name}.split(',').map((s: string) => s.trim()).filter((s: string) => s.length > 0);
540
+ }`).join('\n')}
541
+ ${formFields.filter(f => f.enumValues && f.enumValues.length > 0).length > 0 ? `
542
+ // Enum フィールドの空文字列を undefined に変換(.default() を有効にする)
543
+ ${formFields.filter(f => f.enumValues && f.enumValues.length > 0).map(f => ` if (submitData.${f.name} === '') {
544
+ submitData.${f.name} = undefined;
545
+ }`).join('\n')}` : ''}
546
+ ${formFields.filter(f => f.type === 'number').length > 0 ? `
547
+ // Number フィールドを変換(空文字列 → undefined、文字列 → 数値)
548
+ ${formFields.filter(f => f.type === 'number').map(f => ` if (submitData.${f.name} === '' || submitData.${f.name} === null) {
549
+ submitData.${f.name} = undefined;
550
+ } else if (typeof submitData.${f.name} === 'string') {
551
+ submitData.${f.name} = Number(submitData.${f.name});
552
+ }`).join('\n')}` : ''}
553
+ ${hasNestedSchemas ? `
554
+ // ネストスキーマ参照をIDからオブジェクトに変換
555
+ ${nestedSchemaFields.map(f => {
556
+ const refModelCamel = toCamelCase(f.nestedModelName!);
557
+ const refModelPascal = f.nestedModelName!;
558
+ if (f.isArray) {
559
+ return ` // ${refModelPascal} 配列参照の変換
560
+ if (submitData.${f.name}Ids) {
561
+ submitData.${f.name} = submitData.${f.name}Ids
562
+ .map((refId: string) => ${refModelCamel}Options.find(opt => opt.id === refId))
563
+ .filter(Boolean);
564
+ delete submitData.${f.name}Ids;
565
+ }`;
566
+ } else {
567
+ return ` // ${refModelPascal} 単一参照の変換
568
+ if (submitData.${f.name}Id) {
569
+ const selected = ${refModelCamel}Options.find(opt => opt.id === submitData.${f.name}Id);
570
+ submitData.${f.name} = selected || undefined;
571
+ delete submitData.${f.name}Id;
572
+ } else {
573
+ submitData.${f.name} = undefined;
574
+ delete submitData.${f.name}Id;
575
+ }`;
576
+ }
577
+ }).join('\n')}
578
+ ` : ''}
579
+
580
+ // Validate input (excluding SwallowKit-managed fields)
581
+ ${modelName}InputSchema.parse(submitData);
582
+
583
+ const url = isEdit ? \`/api/${modelCamel}/\${initialData!.id}\` : '/api/${modelCamel}';
584
+ const method = isEdit ? 'PUT' : 'POST';
585
+
586
+ const res = await fetch(url, {
587
+ method,
588
+ headers: { 'Content-Type': 'application/json' },
589
+ body: JSON.stringify(submitData),
590
+ });
591
+
592
+ if (!res.ok) {
593
+ const errorData = await res.json();
594
+ throw new Error(errorData.error || 'Failed to save ${modelCamel}');
595
+ }
596
+
597
+ router.push('/${toKebabCase(modelName)}');
598
+ } catch (err: any) {
599
+ if (err.issues) {
600
+ // Zod validation errors
601
+ const fieldErrors: Record<string, string> = {};
602
+ err.issues.forEach((error: any) => {
603
+ const field = error.path[0];
604
+ fieldErrors[field] = error.message;
605
+ });
606
+ setErrors(fieldErrors);
607
+ } else {
608
+ alert(\`Error: \${err.message}\`);
609
+ }
610
+ setLoading(false);
611
+ }
612
+ };
613
+
614
+ return (
615
+ <form onSubmit={handleSubmit} className="space-y-6">
616
+ ${formFields.map(f => {
617
+ // ネストスキーマの場合はセレクトボックス(単一)またはマルチセレクト(配列)
618
+ if (f.isNestedSchema && f.nestedModelName) {
619
+ const optionsVar = `${toCamelCase(f.nestedModelName)}Options`;
620
+ const label = f.nestedModelName;
621
+
622
+ if (f.isArray) {
623
+ // 配列参照: マルチセレクト
624
+ return ` <div>
625
+ <label htmlFor="${f.name}" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
626
+ ${label}${!f.isOptional ? ' *' : ''} <span className="text-xs text-gray-500">(複数選択可)</span>
627
+ </label>
628
+ <select
629
+ id="${f.name}"
630
+ name="${f.name}"
631
+ multiple
632
+ value={formData.${f.name}Ids}
633
+ onChange={(e) => {
634
+ const selected = Array.from(e.target.selectedOptions, option => option.value);
635
+ setFormData({ ...formData, ${f.name}Ids: selected });
636
+ }}
637
+ className="mt-1 block w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 shadow-sm px-3 py-2 focus:border-blue-500 focus:ring-blue-500 dark:focus:border-blue-400 dark:focus:ring-blue-400 min-h-[120px]"
638
+ ${!f.isOptional ? 'required' : ''}
639
+ >
640
+ {${optionsVar}.map((option) => (
641
+ <option key={option.id} value={option.id}>{option.name}</option>
642
+ ))}
643
+ </select>
644
+ {errors.${f.name} && (
645
+ <p className="mt-1 text-sm text-red-600 dark:text-red-400">{errors.${f.name}}</p>
646
+ )}
647
+ </div>`;
648
+ } else {
649
+ // 単一オブジェクト参照: セレクトボックス
650
+ return ` <div>
651
+ <label htmlFor="${f.name}" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
652
+ ${label}${!f.isOptional ? ' *' : ''}
653
+ </label>
654
+ <select
655
+ id="${f.name}"
656
+ name="${f.name}"
657
+ value={formData.${f.name}Id}
658
+ onChange={(e) => setFormData({ ...formData, ${f.name}Id: e.target.value })}
659
+ className="mt-1 block w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 shadow-sm px-3 py-2 focus:border-blue-500 focus:ring-blue-500 dark:focus:border-blue-400 dark:focus:ring-blue-400"
660
+ ${!f.isOptional ? 'required' : ''}
661
+ >
662
+ <option value="">選択してください</option>
663
+ {${optionsVar}.map((option) => (
664
+ <option key={option.id} value={option.id}>{option.name}</option>
665
+ ))}
666
+ </select>
667
+ {errors.${f.name} && (
668
+ <p className="mt-1 text-sm text-red-600 dark:text-red-400">{errors.${f.name}}</p>
669
+ )}
670
+ </div>`;
671
+ }
672
+ }
673
+
674
+ // 外部キーの場合は参照先モデルのドロップダウン
675
+ if (f.isForeignKey && f.referencedModel) {
676
+ const optionsVar = `${toCamelCase(f.referencedModel)}Options`;
677
+ return ` <div>
678
+ <label htmlFor="${f.name}" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
679
+ ${f.referencedModel}${!f.isOptional ? ' *' : ''}
680
+ </label>
681
+ <select
682
+ id="${f.name}"
683
+ name="${f.name}"
684
+ value={formData.${f.name}}
685
+ onChange={(e) => setFormData({ ...formData, ${f.name}: e.target.value })}
686
+ className="mt-1 block w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 shadow-sm px-3 py-2 focus:border-blue-500 focus:ring-blue-500 dark:focus:border-blue-400 dark:focus:ring-blue-400"
687
+ ${!f.isOptional ? 'required' : ''}
688
+ >
689
+ <option value="">選択してください</option>
690
+ {${optionsVar}.map((option) => (
691
+ <option key={option.id} value={option.id}>{option.name}</option>
692
+ ))}
693
+ </select>
694
+ {errors.${f.name} && (
695
+ <p className="mt-1 text-sm text-red-600 dark:text-red-400">{errors.${f.name}}</p>
696
+ )}
697
+ </div>`;
698
+ }
699
+
700
+ // Enum の場合は select 要素
701
+ if (f.enumValues && f.enumValues.length > 0) {
702
+ return ` <div>
703
+ <label htmlFor="${f.name}" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
704
+ ${f.name}${!f.isOptional ? ' *' : ''}
705
+ </label>
706
+ <select
707
+ id="${f.name}"
708
+ name="${f.name}"
709
+ value={formData.${f.name}}
710
+ onChange={(e) => setFormData({ ...formData, ${f.name}: e.target.value })}
711
+ className="mt-1 block w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 shadow-sm px-3 py-2 focus:border-blue-500 focus:ring-blue-500 dark:focus:border-blue-400 dark:focus:ring-blue-400"
712
+ ${!f.isOptional ? 'required' : ''}
713
+ >
714
+ <option value="">選択してください</option>
715
+ ${f.enumValues.map(v => ` <option value="${v}">${v}</option>`).join('\n')}
716
+ </select>
717
+ {errors.${f.name} && (
718
+ <p className="mt-1 text-sm text-red-600 dark:text-red-400">{errors.${f.name}}</p>
719
+ )}
720
+ </div>`;
721
+ }
722
+
723
+ // Boolean の場合は checkbox
724
+ if (f.type === 'boolean') {
725
+ return ` <div className="flex items-center">
726
+ <input
727
+ type="checkbox"
728
+ id="${f.name}"
729
+ name="${f.name}"
730
+ checked={formData.${f.name}}
731
+ onChange={(e) => setFormData({ ...formData, ${f.name}: e.target.checked })}
732
+ className="h-4 w-4 text-blue-600 dark:text-blue-400 focus:ring-blue-500 dark:focus:ring-blue-400 border border-gray-300 dark:border-gray-600 rounded"
733
+ />
734
+ <label htmlFor="${f.name}" className="ml-2 block text-sm text-gray-700 dark:text-gray-300">
735
+ ${f.name}
736
+ </label>
737
+ {errors.${f.name} && (
738
+ <p className="mt-1 text-sm text-red-600 dark:text-red-400">{errors.${f.name}}</p>
739
+ )}
740
+ </div>`;
741
+ }
742
+
743
+ // Array の場合はカンマ区切りテキスト
744
+ if (f.isArray) {
745
+ return ` <div>
746
+ <label htmlFor="${f.name}" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
747
+ ${f.name}${!f.isOptional ? ' *' : ''} <span className="text-xs text-gray-500">(カンマ区切りで入力)</span>
748
+ </label>
749
+ <input
750
+ type="text"
751
+ id="${f.name}"
752
+ name="${f.name}"
753
+ value={formData.${f.name}}
754
+ onChange={(e) => setFormData({ ...formData, ${f.name}: e.target.value })}
755
+ placeholder="例: item1, item2, item3"
756
+ className="mt-1 block w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 shadow-sm px-3 py-2 focus:border-blue-500 focus:ring-blue-500 dark:focus:border-blue-400 dark:focus:ring-blue-400"
757
+ ${!f.isOptional ? 'required' : ''}
758
+ />
759
+ {errors.${f.name} && (
760
+ <p className="mt-1 text-sm text-red-600 dark:text-red-400">{errors.${f.name}}</p>
761
+ )}
762
+ </div>`;
763
+ }
764
+
765
+ // Number の場合
766
+ if (f.type === 'number') {
767
+ return ` <div>
768
+ <label htmlFor="${f.name}" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
769
+ ${f.name}${!f.isOptional ? ' *' : ''}
770
+ </label>
771
+ <input
772
+ type="number"
773
+ id="${f.name}"
774
+ name="${f.name}"
775
+ value={formData.${f.name}}
776
+ onChange={(e) => setFormData({ ...formData, ${f.name}: e.target.value })}
777
+ className="mt-1 block w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 shadow-sm px-3 py-2 focus:border-blue-500 focus:ring-blue-500 dark:focus:border-blue-400 dark:focus:ring-blue-400"
778
+ ${!f.isOptional ? 'required' : ''}
779
+ />
780
+ {errors.${f.name} && (
781
+ <p className="mt-1 text-sm text-red-600 dark:text-red-400">{errors.${f.name}}</p>
782
+ )}
783
+ </div>`;
784
+ }
785
+
786
+ // Date の場合
787
+ if (f.type === 'date') {
788
+ return ` <div>
789
+ <label htmlFor="${f.name}" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
790
+ ${f.name}${!f.isOptional ? ' *' : ''}
791
+ </label>
792
+ <input
793
+ type="date"
794
+ id="${f.name}"
795
+ name="${f.name}"
796
+ value={formData.${f.name}}
797
+ onChange={(e) => setFormData({ ...formData, ${f.name}: e.target.value })}
798
+ className="mt-1 block w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 shadow-sm px-3 py-2 focus:border-blue-500 focus:ring-blue-500 dark:focus:border-blue-400 dark:focus:ring-blue-400"
799
+ ${!f.isOptional ? 'required' : ''}
800
+ />
801
+ {errors.${f.name} && (
802
+ <p className="mt-1 text-sm text-red-600 dark:text-red-400">{errors.${f.name}}</p>
803
+ )}
804
+ </div>`;
805
+ }
806
+
807
+ // デフォルト: text input
808
+ return ` <div>
809
+ <label htmlFor="${f.name}" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
810
+ ${f.name}${!f.isOptional ? ' *' : ''}
811
+ </label>
812
+ <input
813
+ type="text"
814
+ id="${f.name}"
815
+ name="${f.name}"
816
+ value={formData.${f.name}}
817
+ onChange={(e) => setFormData({ ...formData, ${f.name}: e.target.value })}
818
+ className="mt-1 block w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 shadow-sm px-3 py-2 focus:border-blue-500 focus:ring-blue-500 dark:focus:border-blue-400 dark:focus:ring-blue-400"
819
+ ${!f.isOptional ? 'required' : ''}
820
+ />
821
+ {errors.${f.name} && (
822
+ <p className="mt-1 text-sm text-red-600 dark:text-red-400">{errors.${f.name}}</p>
823
+ )}
824
+ </div>`;
825
+ }).join('\n\n')}
826
+
827
+ <div className="flex gap-4">
828
+ <button
829
+ type="submit"
830
+ disabled={loading}
831
+ className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded disabled:opacity-50"
832
+ >
833
+ {loading ? 'Saving...' : isEdit ? 'Update' : 'Create'}
834
+ </button>
835
+ <button
836
+ type="button"
837
+ onClick={() => router.push('/${toKebabCase(modelName)}')}
838
+ className="bg-gray-300 hover:bg-gray-400 text-gray-800 px-4 py-2 rounded"
839
+ >
840
+ Cancel
841
+ </button>
842
+ </div>
843
+ </form>
844
+ );
845
+ }
846
+ `;
847
+ }
848
+
849
+ /**
850
+ * 新規作成画面を生成
851
+ */
852
+ export function generateNewPage(model: ModelInfo): string {
853
+ const modelName = model.name;
854
+ const modelKebab = toKebabCase(modelName);
855
+
856
+ return `import ${modelName}Form from '../_components/${modelName}Form';
857
+
858
+ export default function New${modelName}Page() {
859
+ return (
860
+ <div className="container mx-auto px-4 py-8">
861
+ <div className="max-w-2xl mx-auto">
862
+ <h1 className="text-3xl font-bold mb-6">Create New ${modelName}</h1>
863
+ <div className="bg-white dark:bg-gray-800 shadow-md rounded-lg p-6">
864
+ <${modelName}Form />
865
+ </div>
866
+ </div>
867
+ </div>
868
+ );
869
+ }
870
+ `;
871
+ }
872
+
873
+ /**
874
+ * 編集画面を生成
875
+ */
876
+ export function generateEditPage(model: ModelInfo, sharedPackageName: string): string {
877
+ const modelName = model.name;
878
+ const modelCamel = toCamelCase(modelName);
879
+ const modelKebab = toKebabCase(modelName);
880
+
881
+ const schemaName = model.schemaName;
882
+ // Zod公式パターン対応: schemaNameとmodelNameが同じ場合はimportエイリアスで名前衝突を回避
883
+ const needsAlias = schemaName === modelName;
884
+ const localSchemaName = needsAlias ? `${toCamelCase(modelName)}Schema` : schemaName;
885
+ const schemaImportLine = needsAlias
886
+ ? `import { ${schemaName} as ${localSchemaName} } from '${sharedPackageName}';`
887
+ : `import { ${schemaName} } from '${sharedPackageName}';`;
888
+
889
+ return `'use client';
890
+
891
+ import { useEffect, useState } from 'react';
892
+ import { useParams } from 'next/navigation';
893
+ import ${modelName}Form from '../../_components/${modelName}Form';
894
+ import { z } from 'zod/v4';
895
+ ${schemaImportLine}
896
+
897
+ type ${modelName} = z.infer<typeof ${localSchemaName}>;
898
+
899
+ export default function Edit${modelName}Page() {
900
+ const params = useParams();
901
+ const [${modelCamel}, set${modelName}] = useState<${modelName} | null>(null);
902
+ const [loading, setLoading] = useState(true);
903
+
904
+ useEffect(() => {
905
+ const id = params?.id as string;
906
+ if (!id) return;
907
+
908
+ fetch(\`/api/${modelCamel}/\${id}\`)
909
+ .then((res) => res.json())
910
+ .then((data) => {
911
+ set${modelName}(data);
912
+ setLoading(false);
913
+ })
914
+ .catch(() => setLoading(false));
915
+ }, [params]);
916
+
917
+ if (loading) {
918
+ return (
919
+ <div className="flex items-center justify-center min-h-screen">
920
+ <div className="text-lg">Loading...</div>
921
+ </div>
922
+ );
923
+ }
924
+
925
+ if (!${modelCamel}) {
926
+ return (
927
+ <div className="flex items-center justify-center min-h-screen">
928
+ <div className="text-red-600">${modelName} not found</div>
929
+ </div>
930
+ );
931
+ }
932
+
933
+ return (
934
+ <div className="container mx-auto px-4 py-8">
935
+ <div className="max-w-2xl mx-auto">
936
+ <h1 className="text-3xl font-bold mb-6">Edit ${modelName}</h1>
937
+ <div className="bg-white dark:bg-gray-800 shadow-md rounded-lg p-6">
938
+ <${modelName}Form initialData={${modelCamel}} isEdit={true} />
939
+ </div>
940
+ </div>
941
+ </div>
942
+ );
943
+ }
944
+ `;
945
+ }