swallowkit 1.0.0-beta.2 → 1.0.0-beta.21

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