@interactive-inc/docs-client 0.0.1

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 (91) hide show
  1. package/.tanstack/tmp/router-generator-Eq20Yx/50b11a0f488e61cf92364a7e585c1448 +123 -0
  2. package/.tanstack/tmp/router-generator-KQIYSa/aecb094e4891f9475b91de590abc8809 +117 -0
  3. package/.tanstack/tmp/router-generator-ekW2pI/aecb094e4891f9475b91de590abc8809 +117 -0
  4. package/app/components/archived-file-list-view.tsx +99 -0
  5. package/app/components/directory-file-list-view.tsx +32 -0
  6. package/app/components/directory-file-tree-node.tsx +96 -0
  7. package/app/components/directory-layout-sidebar.tsx +181 -0
  8. package/app/components/directory-page-view.tsx +208 -0
  9. package/app/components/directory-table-view.tsx +383 -0
  10. package/app/components/editable-table-cell/boolean-editable-cell.tsx +44 -0
  11. package/app/components/editable-table-cell/multi-boolean-editable-cell.tsx +59 -0
  12. package/app/components/editable-table-cell/multi-number-editable-cell.tsx +62 -0
  13. package/app/components/editable-table-cell/multi-relation-editable-cell.tsx +19 -0
  14. package/app/components/editable-table-cell/multi-select-number-editable-cell.tsx +82 -0
  15. package/app/components/editable-table-cell/multi-select-text-editable-cell.tsx +82 -0
  16. package/app/components/editable-table-cell/multi-text-editable-cell.tsx +62 -0
  17. package/app/components/editable-table-cell/number-editable-cell.tsx +54 -0
  18. package/app/components/editable-table-cell/relation-editable-cell.tsx +20 -0
  19. package/app/components/editable-table-cell/select-number-editable-cell.tsx +43 -0
  20. package/app/components/editable-table-cell/select-text-editable-cell.tsx +33 -0
  21. package/app/components/editable-table-cell/string-editable-cell.tsx +42 -0
  22. package/app/components/editable-table-cell.tsx +129 -0
  23. package/app/components/file-card.tsx +120 -0
  24. package/app/components/file-tree-icon.tsx +26 -0
  25. package/app/components/file-view/archive-restore-banner.tsx +86 -0
  26. package/app/components/file-view/csv-file-view.tsx +50 -0
  27. package/app/components/file-view/default-file-view.tsx +11 -0
  28. package/app/components/file-view/editable-csv-cell.tsx +37 -0
  29. package/app/components/file-view/editable-front-matter-view.tsx +105 -0
  30. package/app/components/file-view/file-csv-columns-view.tsx +181 -0
  31. package/app/components/file-view/file-csv-table.tsx +142 -0
  32. package/app/components/file-view/file-header.tsx +115 -0
  33. package/app/components/file-view/file-page-view.tsx +165 -0
  34. package/app/components/file-view/front-matter-input-field.tsx +157 -0
  35. package/app/components/file-view/front-matter-view.tsx +30 -0
  36. package/app/components/file-view/json-file-editor.tsx +38 -0
  37. package/app/components/file-view/markdown-file-view.tsx +71 -0
  38. package/app/components/file-view/multi-relation-select.tsx +129 -0
  39. package/app/components/file-view/render-front-matter-value.tsx +57 -0
  40. package/app/components/file-view/single-relation-select.tsx +41 -0
  41. package/app/components/file-view/sortable-csv-row.tsx +57 -0
  42. package/app/components/file-view/sortable-header.tsx +30 -0
  43. package/app/components/file-view/utils.ts +8 -0
  44. package/app/components/milestones-editor.tsx +95 -0
  45. package/app/components/page-header.tsx +20 -0
  46. package/app/components/project-view/feature-item.tsx +139 -0
  47. package/app/components/project-view/project-page-group.tsx +125 -0
  48. package/app/components/project-view/project-view.tsx +230 -0
  49. package/app/components/project-view/unlinked-features-section.tsx +73 -0
  50. package/app/components/providers.tsx +21 -0
  51. package/app/components/reload-button.tsx +25 -0
  52. package/app/components/sidebar-button.tsx +13 -0
  53. package/app/components/ui/alert.tsx +66 -0
  54. package/app/components/ui/badge.tsx +46 -0
  55. package/app/components/ui/button.tsx +59 -0
  56. package/app/components/ui/card.tsx +92 -0
  57. package/app/components/ui/dialog.tsx +133 -0
  58. package/app/components/ui/emoji-picker.tsx +133 -0
  59. package/app/components/ui/icons.tsx +3 -0
  60. package/app/components/ui/input.tsx +21 -0
  61. package/app/components/ui/select.tsx +183 -0
  62. package/app/components/ui/separator.tsx +26 -0
  63. package/app/components/ui/sheet.tsx +137 -0
  64. package/app/components/ui/sidebar.tsx +724 -0
  65. package/app/components/ui/simple-sidebar.tsx +50 -0
  66. package/app/components/ui/skeleton.tsx +13 -0
  67. package/app/components/ui/sonner.tsx +23 -0
  68. package/app/components/ui/table.tsx +114 -0
  69. package/app/components/ui/tabs.tsx +53 -0
  70. package/app/components/ui/textarea.tsx +18 -0
  71. package/app/components/ui/tooltip.tsx +59 -0
  72. package/app/components/vscode-button.tsx +23 -0
  73. package/app/hooks/use-directory-query.ts +22 -0
  74. package/app/hooks/use-file-properties-mutation.ts +38 -0
  75. package/app/hooks/use-file-query.ts +21 -0
  76. package/app/hooks/use-mobile.ts +19 -0
  77. package/app/main.css +122 -0
  78. package/app/main.tsx +38 -0
  79. package/app/routes/$.index.tsx +25 -0
  80. package/app/routes/$.tsx +20 -0
  81. package/app/routes/__root.tsx +20 -0
  82. package/app/routes/_index.tsx +17 -0
  83. package/app/routes/apps.client.$.tsx +21 -0
  84. package/build/assets/index.css +1 -0
  85. package/build/assets/index.js +298 -0
  86. package/build/index.d.ts +2 -0
  87. package/build/index.d.ts.map +1 -0
  88. package/build/index.js +23 -0
  89. package/components.json +21 -0
  90. package/package.json +54 -0
  91. package/tsconfig.index.json +17 -0
@@ -0,0 +1,383 @@
1
+ import { useMutation, useQueryClient } from "@tanstack/react-query"
2
+ import { Link } from "@tanstack/react-router"
3
+ import { Archive, ArchiveRestore, Plus, Trash2 } from "lucide-react"
4
+ import { useState } from "react"
5
+ import { EditableTableCell } from "@/components/editable-table-cell"
6
+ import { Button } from "@/components/ui/button"
7
+ import { Card } from "@/components/ui/card"
8
+ import {
9
+ Table,
10
+ TableBody,
11
+ TableCell,
12
+ TableHead,
13
+ TableHeader,
14
+ TableRow,
15
+ } from "@/components/ui/table"
16
+ import { apiClient } from "@/lib/api-client"
17
+ import { normalizePath } from "@/lib/path-utils"
18
+ import type { DocFile, DocFileMd, DocRelation, DocSchemaFieldType } from "@/lib/types"
19
+
20
+ /**
21
+ * テーブルカラムの型定義
22
+ */
23
+ type DocTableColumn = {
24
+ key: string
25
+ label: string
26
+ type: DocSchemaFieldType
27
+ path: string
28
+ options: string[] | number[]
29
+ }
30
+
31
+ function isDocFileMd(file: DocFile): file is DocFileMd {
32
+ return file.type === 'markdown'
33
+ }
34
+
35
+ type Props = {
36
+ files: DocFile[]
37
+ columns: DocTableColumn[]
38
+ directoryPath: string
39
+ onDataChanged?: () => void
40
+ relations?: DocRelation[]
41
+ archivedCount?: number
42
+ showArchived?: boolean
43
+ onToggleArchived?: () => void
44
+ }
45
+
46
+ export function DirectoryTableView(props: Props) {
47
+ const queryClient = useQueryClient()
48
+ const [deleteConfirmFiles, setDeleteConfirmFiles] = useState<Set<string>>(
49
+ new Set(),
50
+ )
51
+
52
+ const createFileMutation = useMutation({
53
+ async mutationFn() {
54
+ const endpoint = apiClient.api.files
55
+ const resp = await endpoint.$post({
56
+ json: {
57
+ directoryPath: normalizePath(props.directoryPath),
58
+ },
59
+ })
60
+
61
+ return resp.json()
62
+ },
63
+ onSuccess() {
64
+ // ファイルツリーキャッシュを無効化
65
+ queryClient.invalidateQueries({ queryKey: ["file-tree"] })
66
+
67
+ if (!props.onDataChanged) return
68
+ props.onDataChanged()
69
+ },
70
+ })
71
+
72
+ const updateCellMutation = useMutation({
73
+ async mutationFn(params: { path: string; field: string; value: unknown }) {
74
+ console.log('Updating cell:', params)
75
+ try {
76
+ const endpoint = apiClient.api.files[":path{.+}"]
77
+ const resp = await endpoint.$put({
78
+ param: { path: params.path },
79
+ json: {
80
+ properties: { [params.field]: params.value },
81
+ content: null,
82
+ title: null,
83
+ description: null,
84
+ isArchived: null,
85
+ },
86
+ })
87
+
88
+ console.log('Response status:', resp.status)
89
+
90
+ if (!resp.ok) {
91
+ const errorText = await resp.text()
92
+ console.error('Response error:', errorText)
93
+ throw new Error(`API Error: ${resp.status} - ${errorText}`)
94
+ }
95
+
96
+ const result = await resp.json()
97
+ console.log('Update response:', result)
98
+ return result
99
+ } catch (error) {
100
+ console.error('Update request failed:', error)
101
+ throw error
102
+ }
103
+ },
104
+ onSuccess() {
105
+ // キャッシュを無効化
106
+ queryClient.invalidateQueries({ queryKey: ["file-tree"] })
107
+ queryClient.invalidateQueries({ queryKey: ["directory"] })
108
+
109
+ if (!props.onDataChanged) return
110
+ props.onDataChanged()
111
+ },
112
+ onError(error) {
113
+ console.error('Update mutation failed:', error)
114
+ },
115
+ })
116
+
117
+ const updateTitleMutation = useMutation({
118
+ async mutationFn(params: { path: string; title: string }) {
119
+ const endpoint = apiClient.api.files[":path{.+}"]
120
+ const resp = await endpoint.$put({
121
+ param: { path: params.path },
122
+ json: {
123
+ title: params.title,
124
+ properties: null,
125
+ content: null,
126
+ description: null,
127
+ isArchived: null,
128
+ },
129
+ })
130
+
131
+ return resp.json()
132
+ },
133
+ onSuccess() {
134
+ if (!props.onDataChanged) return
135
+ props.onDataChanged()
136
+ },
137
+ })
138
+
139
+ const deleteFileMutation = useMutation({
140
+ async mutationFn(filePath: string) {
141
+ // パスを正規化
142
+ const normalizedPath = normalizePath(filePath)
143
+
144
+ // apiClientを使用して削除
145
+ const endpoint = apiClient.api.files[":path{.+}"]
146
+ const resp = await endpoint.$delete({
147
+ param: { path: normalizedPath },
148
+ })
149
+
150
+ if (!resp.ok) {
151
+ const errorText = await resp.text()
152
+ throw new Error(`Failed to delete file: ${errorText}`)
153
+ }
154
+
155
+ return resp.json()
156
+ },
157
+ onSuccess() {
158
+ // ファイルツリーキャッシュを無効化
159
+ queryClient.invalidateQueries({ queryKey: ["file-tree"] })
160
+
161
+ if (!props.onDataChanged) return
162
+ props.onDataChanged()
163
+ },
164
+ })
165
+
166
+ const archiveFileMutation = useMutation({
167
+ async mutationFn(filePath: string) {
168
+ // パスを正規化
169
+ const normalizedPath = normalizePath(filePath)
170
+
171
+ const endpoint = apiClient.api.files[":path{.+}"]
172
+ const resp = await endpoint.$put({
173
+ param: { path: normalizedPath },
174
+ json: {
175
+ properties: null,
176
+ content: null,
177
+ title: null,
178
+ description: null,
179
+ isArchived: true,
180
+ },
181
+ })
182
+
183
+ return resp.json()
184
+ },
185
+ onSuccess() {
186
+ // ファイルツリーキャッシュを無効化
187
+ queryClient.invalidateQueries({ queryKey: ["file-tree"] })
188
+
189
+ if (!props.onDataChanged) return
190
+ props.onDataChanged()
191
+ },
192
+ })
193
+
194
+ const restoreFileMutation = useMutation({
195
+ async mutationFn(filePath: string) {
196
+ // パスを正規化
197
+ const normalizedPath = normalizePath(filePath)
198
+
199
+ const endpoint = apiClient.api.files[":path{.+}"]
200
+ const resp = await endpoint.$put({
201
+ param: { path: normalizedPath },
202
+ json: {
203
+ properties: null,
204
+ content: null,
205
+ title: null,
206
+ description: null,
207
+ isArchived: false,
208
+ },
209
+ })
210
+
211
+ return resp.json()
212
+ },
213
+ onSuccess() {
214
+ // ファイルツリーキャッシュを無効化
215
+ queryClient.invalidateQueries({ queryKey: ["file-tree"] })
216
+
217
+ if (!props.onDataChanged) return
218
+ props.onDataChanged()
219
+ },
220
+ })
221
+
222
+
223
+ const handleDeleteClick = (filePath: string) => {
224
+ if (deleteConfirmFiles.has(filePath)) {
225
+ // 2回目のクリック - ファイルを削除
226
+ deleteFileMutation.mutate(filePath)
227
+ setDeleteConfirmFiles(new Set())
228
+ } else {
229
+ // 1回目のクリック - 確認状態にする
230
+ setDeleteConfirmFiles(new Set([filePath]))
231
+ }
232
+ }
233
+
234
+ const handleArchiveClick = (filePath: string) => {
235
+ archiveFileMutation.mutate(filePath)
236
+ }
237
+
238
+ const handleRestoreClick = (filePath: string) => {
239
+ restoreFileMutation.mutate(filePath)
240
+ }
241
+
242
+ return (
243
+ <Card className="gap-0 overflow-x-scroll rounded-md p-0">
244
+ <Table>
245
+ <TableHeader>
246
+ <TableRow>
247
+ <TableHead className="w-40">ファイル名</TableHead>
248
+ <TableHead className="min-w-64">タイトル</TableHead>
249
+ {props.columns.map((column) => (
250
+ <TableHead key={column.key}>{column.label}</TableHead>
251
+ ))}
252
+ <TableHead className="w-16" />
253
+ </TableRow>
254
+ </TableHeader>
255
+ <TableBody>
256
+ {props.files.filter(isDocFileMd).map((fileData) => {
257
+ const isArchived = 'isArchived' in fileData && fileData.isArchived
258
+ return (
259
+ <TableRow
260
+ key={fileData.path.path}
261
+ className={isArchived ? "opacity-60" : ""}
262
+ >
263
+ <TableCell className="font-medium">
264
+ <Link
265
+ to="/$"
266
+ params={{ _splat: normalizePath(fileData.path.path) }}
267
+ className="text-blue-600 hover:underline"
268
+ >
269
+ {fileData.path.name}
270
+ </Link>
271
+ </TableCell>
272
+ <TableCell className="p-0">
273
+ <EditableTableCell
274
+ value={fileData.content.title || ""}
275
+ type="string"
276
+ onUpdate={(newValue) => {
277
+ return updateTitleMutation.mutate({
278
+ path: fileData.path.path,
279
+ title: String(newValue),
280
+ })
281
+ }}
282
+ />
283
+ </TableCell>
284
+ {props.columns.map((column) => {
285
+ // リレーション情報を取得
286
+ const relationData = props.relations?.find(
287
+ (rel) => rel.path === column.path,
288
+ )
289
+
290
+ const cellValue = fileData.content.frontMatter?.[column.key]
291
+
292
+ return (
293
+ <TableCell key={column.key} className="p-1">
294
+ <EditableTableCell
295
+ value={cellValue}
296
+ type={column.type}
297
+ path={column.path}
298
+ relationOptions={relationData?.files}
299
+ selectOptions={column.options}
300
+ onUpdate={(newValue) => {
301
+ return updateCellMutation.mutate({
302
+ path: fileData.path.path,
303
+ field: column.key,
304
+ value: newValue,
305
+ })
306
+ }}
307
+ />
308
+ </TableCell>
309
+ )
310
+ })}
311
+ <TableCell className="p-2">
312
+ <div className="flex justify-end gap-1">
313
+ {!isArchived && (
314
+ <Button
315
+ size="sm"
316
+ variant="ghost"
317
+ onClick={() => handleArchiveClick(fileData.path.path)}
318
+ disabled={archiveFileMutation.isPending}
319
+ className="h-8 w-8 p-0"
320
+ title="アーカイブする"
321
+ >
322
+ <Archive className="h-4 w-4" />
323
+ </Button>
324
+ )}
325
+ {isArchived && (
326
+ <Button
327
+ size="sm"
328
+ variant="ghost"
329
+ onClick={() => handleRestoreClick(fileData.path.path)}
330
+ disabled={restoreFileMutation.isPending}
331
+ className="h-8 w-8 p-0"
332
+ title="復元する"
333
+ >
334
+ <ArchiveRestore className="h-4 w-4" />
335
+ </Button>
336
+ )}
337
+ <Button
338
+ size="sm"
339
+ variant={
340
+ deleteConfirmFiles.has(fileData.path.path)
341
+ ? "destructive"
342
+ : "ghost"
343
+ }
344
+ onClick={() => handleDeleteClick(fileData.path.path)}
345
+ disabled={deleteFileMutation.isPending}
346
+ className="h-8 w-8 p-0"
347
+ title="削除する"
348
+ >
349
+ <Trash2 className="h-4 w-4" />
350
+ </Button>
351
+ </div>
352
+ </TableCell>
353
+ </TableRow>
354
+ )
355
+ })}
356
+ </TableBody>
357
+ </Table>
358
+ <div className="border-t p-2 flex gap-2">
359
+ <Button
360
+ size="sm"
361
+ variant="secondary"
362
+ onClick={() => createFileMutation.mutate()}
363
+ disabled={createFileMutation.isPending}
364
+ >
365
+ <Plus className="h-4 w-4" />
366
+ {"新しいファイル"}
367
+ </Button>
368
+ {(props.archivedCount ?? 0) > 0 && (
369
+ <Button
370
+ size="sm"
371
+ variant={props.showArchived ? "default" : "outline"}
372
+ onClick={props.onToggleArchived}
373
+ >
374
+ {props.showArchived
375
+ ? `表示中(${props.archivedCount}件)`
376
+ : `非表示(${props.archivedCount}件)`
377
+ }
378
+ </Button>
379
+ )}
380
+ </div>
381
+ </Card>
382
+ )
383
+ }
@@ -0,0 +1,44 @@
1
+ import {
2
+ Select,
3
+ SelectContent,
4
+ SelectItem,
5
+ SelectTrigger,
6
+ SelectValue,
7
+ } from "@/components/ui/select"
8
+
9
+ type Props = {
10
+ value: unknown
11
+ onUpdate: (value: unknown) => void
12
+ }
13
+
14
+ export function BooleanEditableCell(props: Props) {
15
+ return (
16
+ <Select
17
+ value={
18
+ props.value === true
19
+ ? "true"
20
+ : props.value === false
21
+ ? "false"
22
+ : "undefined"
23
+ }
24
+ onValueChange={(value) => {
25
+ if (value === "true") {
26
+ props.onUpdate(true)
27
+ } else if (value === "false") {
28
+ props.onUpdate(false)
29
+ } else {
30
+ props.onUpdate(undefined)
31
+ }
32
+ }}
33
+ >
34
+ <SelectTrigger className="w-full">
35
+ <SelectValue placeholder="選択してください" />
36
+ </SelectTrigger>
37
+ <SelectContent>
38
+ <SelectItem value="undefined">-</SelectItem>
39
+ <SelectItem value="true">true</SelectItem>
40
+ <SelectItem value="false">false</SelectItem>
41
+ </SelectContent>
42
+ </Select>
43
+ )
44
+ }
@@ -0,0 +1,59 @@
1
+ import { useEffect, useState } from "react"
2
+ import { Input } from "@/components/ui/input"
3
+
4
+ type Props = {
5
+ value: unknown
6
+ onUpdate: (value: unknown) => void
7
+ }
8
+
9
+ export function MultiBooleanEditableCell(props: Props) {
10
+ const formatValue = (value: unknown): string => {
11
+ if (value === undefined || value === null) {
12
+ return ""
13
+ }
14
+ return Array.isArray(value) ? value.join(", ") : ""
15
+ }
16
+
17
+ const [editValue, setEditValue] = useState(formatValue(props.value))
18
+
19
+ // biome-ignore lint/correctness/useExhaustiveDependencies: <explanation>
20
+ useEffect(() => {
21
+ setEditValue(formatValue(props.value))
22
+ }, [props.value])
23
+
24
+ const parseValue = (text: string): unknown => {
25
+ if (text === "") {
26
+ return undefined
27
+ }
28
+ return text.split(",").map((s) => s.trim().toLowerCase() === "true")
29
+ }
30
+
31
+ const displayValue = formatValue(props.value)
32
+
33
+ const handleBlur = () => {
34
+ const parsedValue = parseValue(editValue)
35
+ if (parsedValue !== props.value) {
36
+ props.onUpdate(parsedValue)
37
+ }
38
+ }
39
+
40
+ const handleKeyDown = (e: React.KeyboardEvent) => {
41
+ if (e.key === "Enter") {
42
+ handleBlur()
43
+ }
44
+ if (e.key === "Escape") {
45
+ setEditValue(displayValue)
46
+ }
47
+ }
48
+
49
+ return (
50
+ <Input
51
+ type="text"
52
+ value={editValue}
53
+ onChange={(e) => setEditValue(e.target.value)}
54
+ onBlur={handleBlur}
55
+ onKeyDown={handleKeyDown}
56
+ placeholder={displayValue || "-"}
57
+ />
58
+ )
59
+ }
@@ -0,0 +1,62 @@
1
+ import { useEffect, useState } from "react"
2
+ import { Input } from "@/components/ui/input"
3
+
4
+ type Props = {
5
+ value: unknown
6
+ onUpdate: (value: unknown) => void
7
+ }
8
+
9
+ export function MultiNumberEditableCell(props: Props) {
10
+ const formatValue = (value: unknown): string => {
11
+ if (value === undefined || value === null) {
12
+ return ""
13
+ }
14
+ return Array.isArray(value) ? value.join(", ") : ""
15
+ }
16
+
17
+ const [editValue, setEditValue] = useState(formatValue(props.value))
18
+
19
+ // biome-ignore lint/correctness/useExhaustiveDependencies: <explanation>
20
+ useEffect(() => {
21
+ setEditValue(formatValue(props.value))
22
+ }, [props.value])
23
+
24
+ const parseValue = (text: string): unknown => {
25
+ if (text === "") {
26
+ return undefined
27
+ }
28
+ return text.split(",").map((s) => {
29
+ const n = Number(s.trim())
30
+ return Number.isNaN(n) ? 0 : n
31
+ })
32
+ }
33
+
34
+ const displayValue = formatValue(props.value)
35
+
36
+ const handleBlur = () => {
37
+ const parsedValue = parseValue(editValue)
38
+ if (parsedValue !== props.value) {
39
+ props.onUpdate(parsedValue)
40
+ }
41
+ }
42
+
43
+ const handleKeyDown = (e: React.KeyboardEvent) => {
44
+ if (e.key === "Enter") {
45
+ handleBlur()
46
+ }
47
+ if (e.key === "Escape") {
48
+ setEditValue(displayValue)
49
+ }
50
+ }
51
+
52
+ return (
53
+ <Input
54
+ type="text"
55
+ value={editValue}
56
+ onChange={(e) => setEditValue(e.target.value)}
57
+ onBlur={handleBlur}
58
+ onKeyDown={handleKeyDown}
59
+ placeholder={displayValue || "-"}
60
+ />
61
+ )
62
+ }
@@ -0,0 +1,19 @@
1
+ import { MultiRelationSelect } from "@/components/file-view/multi-relation-select"
2
+ import type { DocRelationFile } from "@/lib/types"
3
+
4
+ type Props = {
5
+ value: unknown
6
+ onUpdate: (value: unknown) => void
7
+ relationOptions?: DocRelationFile[]
8
+ }
9
+
10
+ export function MultiRelationEditableCell(props: Props) {
11
+ // MultiRelationSelectはDocRelationFileを直接受け取る
12
+ return (
13
+ <MultiRelationSelect
14
+ value={Array.isArray(props.value) ? props.value : []}
15
+ relationOptions={props.relationOptions}
16
+ onValueChange={(value) => props.onUpdate(value)}
17
+ />
18
+ )
19
+ }
@@ -0,0 +1,82 @@
1
+ import { X } from "lucide-react"
2
+ import { useState } from "react"
3
+ import { Badge } from "@/components/ui/badge"
4
+ import { Button } from "@/components/ui/button"
5
+
6
+ type Props = {
7
+ value: number[]
8
+ options: number[]
9
+ onUpdate: (value: number[]) => void
10
+ }
11
+
12
+ /**
13
+ * 複数選択数値セル
14
+ */
15
+ export function MultiSelectNumberEditableCell(props: Props) {
16
+ const [isOpen, setIsOpen] = useState(false)
17
+ const currentValues = props.value || []
18
+
19
+ const handleToggle = (option: number) => {
20
+ const isSelected = currentValues.includes(option)
21
+ const newValues = isSelected
22
+ ? currentValues.filter((v) => v !== option)
23
+ : [...currentValues, option]
24
+ props.onUpdate(newValues)
25
+ }
26
+
27
+ const handleRemove = (value: number) => {
28
+ const newValues = currentValues.filter((v) => v !== value)
29
+ props.onUpdate(newValues)
30
+ }
31
+
32
+ return (
33
+ <div className="relative">
34
+ <div className="flex min-h-8 flex-wrap gap-1 rounded-md border p-2">
35
+ {currentValues.map((value) => (
36
+ <Badge
37
+ key={value}
38
+ variant="secondary"
39
+ className="flex items-center gap-1"
40
+ >
41
+ {value}
42
+ <Button
43
+ size="sm"
44
+ variant="ghost"
45
+ className="h-4 w-4 p-0"
46
+ onClick={() => handleRemove(value)}
47
+ >
48
+ <X className="h-3 w-3" />
49
+ </Button>
50
+ </Badge>
51
+ ))}
52
+ <Button
53
+ variant="ghost"
54
+ size="sm"
55
+ onClick={() => setIsOpen(!isOpen)}
56
+ className="text-muted-foreground"
57
+ >
58
+ 追加
59
+ </Button>
60
+ </div>
61
+ {isOpen && (
62
+ <div className="absolute z-10 mt-1 w-full rounded-md border bg-white shadow-lg">
63
+ {props.options
64
+ .filter((option) => !currentValues.includes(option))
65
+ .map((option) => (
66
+ <button
67
+ type="button"
68
+ key={option}
69
+ className="w-full px-3 py-2 text-left hover:bg-gray-100"
70
+ onClick={() => {
71
+ handleToggle(option)
72
+ setIsOpen(false)
73
+ }}
74
+ >
75
+ {option}
76
+ </button>
77
+ ))}
78
+ </div>
79
+ )}
80
+ </div>
81
+ )
82
+ }