Skip to content

Commit 3e1e86b

Browse files
アカウント一覧ページにCSVインポート機能を追加 (#32)
* feat: アカウント一覧ページにCSVインポート機能を追加 Co-Authored-By: 村上雅彦 <[email protected]> * chore: add csv-parse dependency Co-Authored-By: 村上雅彦 <[email protected]> * feat: add CSV import dialog component and remove old CSV import form --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: 村上雅彦 <[email protected]>
1 parent f251f86 commit 3e1e86b

File tree

4 files changed

+246
-39
lines changed

4 files changed

+246
-39
lines changed
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import * as React from "react";
2+
import { useRef, useState } from "react";
3+
import { Button } from "./ui/button";
4+
import { Input } from "./ui/input";
5+
import { Form } from "react-router";
6+
7+
import {
8+
Dialog,
9+
DialogClose,
10+
DialogContent,
11+
DialogDescription,
12+
DialogHeader,
13+
DialogTitle,
14+
DialogTrigger,
15+
} from "./ui/dialog";
16+
17+
export type CSVImportDialogProps = {
18+
title: string;
19+
description: string;
20+
headers: {
21+
name: string;
22+
required: boolean;
23+
}[];
24+
};
25+
26+
export function CSVImportDialog({
27+
title,
28+
description,
29+
headers,
30+
}: CSVImportDialogProps) {
31+
const [fileName, setFileName] = useState<string | null>(null);
32+
const fileInputRef = useRef<HTMLInputElement>(null);
33+
34+
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
35+
const file = e.target.files?.[0];
36+
setFileName(file?.name || null);
37+
};
38+
39+
return (
40+
<Dialog aria-label="CSV Import Dialog">
41+
<DialogTrigger asChild>
42+
<Button variant="outline">CSVからインポート</Button>
43+
</DialogTrigger>
44+
<DialogContent className="sm:max-w-md">
45+
<DialogHeader>
46+
<DialogTitle>{title}</DialogTitle>
47+
</DialogHeader>
48+
<DialogDescription>{description}</DialogDescription>
49+
<Form method="post" encType="multipart/form-data">
50+
<div className="grid gap-4 py-4">
51+
<div className="flex flex-col gap-2">
52+
<label htmlFor="csvFile" className="text-sm font-medium">
53+
CSVファイル
54+
</label>
55+
<Input
56+
id="csvFile"
57+
name="csvFile"
58+
type="file"
59+
accept=".csv"
60+
ref={fileInputRef}
61+
onChange={handleFileChange}
62+
required
63+
/>
64+
{fileName && (
65+
<p className="text-sm text-gray-500">
66+
選択されたファイル: {fileName}
67+
</p>
68+
)}
69+
</div>
70+
<div className="mb-2">
71+
<p className="text-sm text-gray-500">
72+
CSVファイルには、以下のヘッダーを含めてください:
73+
</p>
74+
<p className="text-xs text-gray-500 mt-1">
75+
{headers.map((h) => h.name).join(",")}
76+
</p>
77+
<p className="text-xs text-gray-500 mt-1">
78+
79+
{headers
80+
.filter((h) => h.required)
81+
.map((h) => h.name)
82+
.join(",")}
83+
は必須です
84+
</p>
85+
</div>
86+
</div>
87+
<div className="flex justify-end">
88+
<DialogClose asChild>
89+
<Button type="submit">インポート</Button>
90+
</DialogClose>
91+
</div>
92+
</Form>
93+
</DialogContent>
94+
</Dialog>
95+
);
96+
}

app/routes/accounts.tsx

Lines changed: 148 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
// No need to import React with modern JSX transform
2-
import { useState, useEffect } from "react";
3-
import type { Schema } from "../../amplify/data/resource";
42
import { Button } from "../components/ui/button";
53
import { AccountCard } from "../components/account-card";
64
import { client } from "../lib/amplify-client";
75
import { useNavigate } from "react-router";
6+
import { parse } from "csv-parse/browser/esm";
7+
import { CSVImportDialog } from "../components/csv-import-dialog";
8+
import type { Route } from "./+types/accounts";
89

910
export function meta() {
1011
return [
@@ -13,62 +14,171 @@ export function meta() {
1314
];
1415
}
1516

16-
type Account = Schema["Account"]["type"];
17+
export async function clientAction({ request }: { request: Request }) {
18+
const formData = await request.formData();
19+
const csvFile = formData.get("csvFile") as File;
1720

18-
export default function Accounts() {
19-
const [accounts, setAccounts] = useState<Account[]>([]);
20-
const [loading, setLoading] = useState(true);
21-
const [error, setError] = useState<Error | null>(null);
21+
if (!csvFile) {
22+
return { error: "CSVファイルが必要です" };
23+
}
24+
25+
try {
26+
const text = await csvFile.text();
27+
const parseCSV = () => {
28+
return new Promise<Array<Record<string, string>>>((resolve, reject) => {
29+
parse(
30+
text,
31+
{
32+
columns: true,
33+
skip_empty_lines: true,
34+
trim: true,
35+
},
36+
(err, records) => {
37+
if (err) {
38+
reject(err);
39+
} else {
40+
resolve(records);
41+
}
42+
},
43+
);
44+
});
45+
};
46+
47+
const records = await parseCSV();
48+
49+
const results = {
50+
success: 0,
51+
errors: [] as string[],
52+
};
53+
54+
for (let i = 0; i < records.length; i++) {
55+
const record = records[i];
56+
57+
if (
58+
!record.name ||
59+
!record.email ||
60+
!record.organizationLine ||
61+
!record.residence
62+
) {
63+
results.errors.push(
64+
`行 ${i + 1}: 名前、メール、組織、居住地は必須です`,
65+
);
66+
continue;
67+
}
68+
69+
try {
70+
const { errors } = await client.models.Account.create({
71+
name: record.name,
72+
email: record.email,
73+
photo: record.photo || undefined,
74+
organizationLine: record.organizationLine,
75+
residence: record.residence,
76+
});
77+
78+
if (errors) {
79+
results.errors.push(
80+
`行 ${i + 1}: ${errors.map((err) => err.message).join(", ")}`,
81+
);
82+
} else {
83+
results.success++;
84+
}
85+
} catch (error) {
86+
results.errors.push(
87+
`行 ${i + 1}: ${error instanceof Error ? error.message : "不明なエラー"}`,
88+
);
89+
}
90+
}
91+
92+
return {
93+
results,
94+
};
95+
} catch (error) {
96+
return {
97+
error: `CSVの処理中にエラーが発生しました: ${error instanceof Error ? error.message : "不明なエラー"}`,
98+
};
99+
}
100+
}
101+
102+
export async function clientLoader() {
103+
try {
104+
const { data } = await client.models.Account.list();
105+
return { accounts: data };
106+
} catch (err) {
107+
console.error("Error fetching accounts:", err);
108+
return {
109+
accounts: [],
110+
error: err instanceof Error ? err.message : "Unknown error occurred",
111+
};
112+
}
113+
}
114+
115+
export default function Accounts({
116+
loaderData,
117+
actionData,
118+
}: Route.ComponentProps) {
22119
const navigate = useNavigate();
23120
const handleAccountClick = (accountId: string) => {
24121
navigate(`/accounts/${accountId}`);
25122
};
26-
const fetchAccounts = async () => {
27-
try {
28-
setLoading(true);
29-
const { data } = await client.models.Account.list();
30-
setAccounts(data);
31-
} catch (err) {
32-
console.error("Error fetching accounts:", err);
33-
setError(
34-
err instanceof Error ? err : new Error("Unknown error occurred"),
35-
);
36-
} finally {
37-
setLoading(false);
38-
}
39-
};
40-
41-
useEffect(() => {
42-
fetchAccounts();
43-
}, []);
44123

45-
// handleAccountCreated removed as it's no longer needed
124+
const { accounts, error } = loaderData;
46125

47126
return (
48127
<div className="container mx-auto p-4">
49128
<div className="flex justify-between items-center mb-6">
50129
<h1 className="text-2xl font-bold">Accounts</h1>
51-
<Button onClick={() => navigate("/accounts/new")}>Add Account</Button>
130+
<div className="flex gap-2">
131+
<CSVImportDialog
132+
title="アカウントインポート"
133+
description="CSVファイルからアカウントをインポートします。"
134+
headers={[
135+
{ name: "name", required: true },
136+
{ name: "email", required: true },
137+
{ name: "photo", required: false },
138+
{ name: "organizationLine", required: true },
139+
{ name: "residence", required: true },
140+
]}
141+
/>
142+
<Button onClick={() => navigate("/accounts/new")}>Add Account</Button>
143+
</div>
52144
</div>
53145

54-
{/* Inline form removed */}
146+
{loaderData.error && (
147+
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
148+
エラー: {loaderData.error}
149+
</div>
150+
)}
151+
152+
{actionData?.results && (
153+
<div
154+
className={`px-4 py-3 rounded mb-4 ${actionData?.results?.errors.length ? "bg-yellow-100 border border-yellow-400 text-yellow-700" : "bg-green-100 border border-green-400 text-green-700"}`}
155+
>
156+
<p>
157+
{actionData.results.success}
158+
件のアカウントが正常にインポートされました。
159+
</p>
160+
{actionData?.results?.errors.length && (
161+
<>
162+
<p className="font-bold mt-2">
163+
{actionData.results.errors.length}件のエラーがありました:
164+
</p>
165+
<ul className="list-disc pl-5 mt-1">
166+
{actionData?.results?.errors.map((err, idx) => (
167+
<li key={idx}>{err}</li>
168+
))}
169+
</ul>
170+
</>
171+
)}
172+
</div>
173+
)}
55174

56175
{error && (
57176
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
58177
Error: {error.toString()}
59178
</div>
60179
)}
61180

62-
{loading ? (
63-
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
64-
{[...Array(4)].map((_, index) => (
65-
<div
66-
key={index}
67-
className="h-48 bg-gray-200 rounded-lg animate-pulse"
68-
></div>
69-
))}
70-
</div>
71-
) : accounts.length === 0 ? (
181+
{accounts.length === 0 ? (
72182
<div className="text-center py-8">
73183
<p className="text-gray-500">
74184
No accounts found. Add an account to get started.

package-lock.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
"aws-amplify": "^6.12.3",
3131
"class-variance-authority": "^0.7.1",
3232
"clsx": "^2.1.1",
33+
"csv-parse": "^5.6.0",
3334
"express": "^4.21.2",
3435
"isbot": "^5.1.17",
3536
"lucide-react": "^0.475.0",

0 commit comments

Comments
 (0)