Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions amplify/data/resource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ const schema = a
description: a.string(),
projects: a.hasMany("ProjectTechnologyLink", "technologyId"),
})
.secondaryIndexes((index) => [index("name")])
.authorization((allow) => [allow.authenticated()]),

ProjectAssignment: a
Expand Down
49 changes: 44 additions & 5 deletions app/components/csv-import-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import * as React from "react";
import { useRef, useState } from "react";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { Form } from "react-router";
import { Form, useNavigation, useActionData } from "react-router";
import { Progress } from "./ui/progress";

import {
Dialog,
Expand All @@ -21,21 +22,43 @@ export type CSVImportDialogProps = {
name: string;
required: boolean;
}[];
maxProgressValue?: number;
};

export function CSVImportDialog({
title,
description,
headers,
maxProgressValue = 100,
}: CSVImportDialogProps) {
const [fileName, setFileName] = useState<string | null>(null);
const [isImporting, setIsImporting] = useState(false);
const [progress, setProgress] = useState(0);
const fileInputRef = useRef<HTMLInputElement>(null);
const navigation = useNavigation();
const actionData = useActionData();

const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
setFileName(file?.name || null);
setProgress(0);
};

React.useEffect(() => {
if (navigation.state === "submitting") {
setIsImporting(true);

const interval = setInterval(() => {
setProgress((prev) => Math.min(prev + 5, 95)); // 95%まで進行(完了は結果表示と同時に)
}, 300);

return () => clearInterval(interval);
} else if (navigation.state === "idle" && actionData && isImporting) {
setProgress(100); // 完了時に100%に設定
setIsImporting(false);
}
}, [navigation.state, actionData, isImporting]);

return (
<Dialog aria-label="CSV Import Dialog">
<DialogTrigger asChild>
Expand Down Expand Up @@ -84,10 +107,26 @@ export function CSVImportDialog({
</p>
</div>
</div>
<div className="flex justify-end">
<DialogClose asChild>
<Button type="submit">インポート</Button>
</DialogClose>
<div className="flex flex-col gap-4">
{isImporting && (
<div className="space-y-2">
<Progress
value={progress}
max={maxProgressValue}
className="w-full"
/>
<p className="text-xs text-center text-gray-500">
{progress < 100 ? "インポート中..." : "インポート完了"}
</p>
</div>
)}
<div className="flex justify-end">
<DialogClose asChild>
<Button type="submit" disabled={isImporting}>
インポート
</Button>
</DialogClose>
</div>
</div>
</Form>
</DialogContent>
Expand Down
29 changes: 29 additions & 0 deletions app/components/ui/progress.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"

import { cn } from "~/lib/utils"

function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
)
}

export { Progress }
145 changes: 142 additions & 3 deletions app/routes/project-technologies.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
import { useNavigate } from "react-router";
import { Button } from "../components/ui/button";
import { client } from "../lib/amplify-client";
import { parse } from "csv-parse/browser/esm";
import { CSVImportDialog } from "../components/csv-import-dialog";
import {
Table,
TableBody,
Expand All @@ -23,6 +25,102 @@ export function meta() {
];
}

export async function clientAction({ request }: { request: Request }) {
const formData = await request.formData();
const csvFile = formData.get("csvFile") as File;

if (!csvFile) {
return { error: "CSVファイルが必要です" };
}

try {
const text = await csvFile.text();
const parseCSV = () => {
return new Promise<Array<Record<string, string>>>((resolve, reject) => {
parse(
text,
{
columns: true,
skip_empty_lines: true,
trim: true,
},
(err, records) => {
if (err) {
reject(err);
} else {
resolve(records);
}
},
);
});
};

const records = await parseCSV();

const results = {
success: 0,
errors: [] as string[],
};

for (let i = 0; i < records.length; i++) {
const record = records[i];

if (!record.name) {
results.errors.push(`行 ${i + 1}: 名前は必須です`);
continue;
}

try {
const { data: existingTech } =
await client.models.ProjectTechnology.listProjectTechnologyByName({
name: record.name,
});

if (existingTech.length > 0) {
const { errors } = await client.models.ProjectTechnology.update({
id: existingTech[0].id,
name: record.name,
description: record.description || existingTech[0].description,
});

if (errors) {
results.errors.push(
`行 ${i + 1}: ${errors.map((err) => err.message).join(", ")}`,
);
} else {
results.success++;
}
} else {
const { errors } = await client.models.ProjectTechnology.create({
name: record.name,
description: record.description || undefined,
});

if (errors) {
results.errors.push(
`行 ${i + 1}: ${errors.map((err) => err.message).join(", ")}`,
);
} else {
results.success++;
}
}
} catch (error) {
results.errors.push(
`行 ${i + 1}: ${error instanceof Error ? error.message : "不明なエラー"}`,
);
}
}

return {
results,
};
} catch (error) {
return {
error: `CSVの処理中にエラーが発生しました: ${error instanceof Error ? error.message : "不明なエラー"}`,
};
}
}

export async function clientLoader() {
try {
const { data } = await client.models.ProjectTechnology.list({
Expand All @@ -40,6 +138,7 @@ export async function clientLoader() {

export default function ProjectTechnologies({
loaderData,
actionData,
}: Route.ComponentProps) {
const { projectTechnologies = [], error } = loaderData || {
projectTechnologies: [],
Expand All @@ -51,11 +150,51 @@ export default function ProjectTechnologies({
<div className="container mx-auto p-4">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">Project Technologies</h1>
<Button onClick={() => navigate("/project-technologies/new")}>
Add Project Technology
</Button>
<div className="flex gap-2">
<CSVImportDialog
title="プロジェクト技術インポート"
description="CSVファイルからプロジェクト技術をインポートします。"
headers={[
{ name: "name", required: true },
{ name: "description", required: false },
]}
maxProgressValue={100}
/>
<Button onClick={() => navigate("/project-technologies/new")}>
Add Project Technology
</Button>
</div>
</div>

{actionData?.results && (
<div
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"}`}
>
<p>
{actionData.results.success}
件のプロジェクト技術が正常にインポートされました。
</p>
{actionData?.results?.errors.length > 0 && (
<>
<p className="font-bold mt-2">
{actionData.results.errors.length}件のエラーがありました:
</p>
<ul className="list-disc pl-5 mt-1">
{actionData.results.errors.map((err, idx) => (
<li key={idx}>{err}</li>
))}
</ul>
</>
)}
</div>
)}

{actionData?.error && (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
エラー: {actionData.error}
</div>
)}

{error && (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
Error: {error}
Expand Down
25 changes: 25 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-progress": "^1.1.2",
"@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-separator": "^1.1.2",
"@radix-ui/react-slot": "^1.1.2",
Expand Down