forked from baron/baron-sso
merge: integrate origin dev into dev
Includes Worksmobile SSOT sync comparison updates, UUID import conflict resolution, and Playwright route mock stabilization.
This commit is contained in:
@@ -302,22 +302,6 @@ export function UserBulkUploadModal({
|
||||
});
|
||||
};
|
||||
|
||||
const downloadTemplate = () => {
|
||||
const headers =
|
||||
"email,sub_email,name,phone,role,tenant_slug,department,grade,position,jobTitle,employee_id,tenant_slug1,department1,grade1,position1,jobTitle1,employee_id1";
|
||||
const example =
|
||||
"user1@example.com,sub1@test.com;sub2@test.com,홍길동,010-1234-5678,user,tenant-slug,개발팀,수석,팀장,프론트엔드,EMP001,second-tenant,센터,책임,,Architecture,EMP002";
|
||||
const blob = new Blob([`${headers}\n${example}`], {
|
||||
type: "text/csv;charset=utf-8;",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "user_bulk_template.csv";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setFile(null);
|
||||
setPreviewData([]);
|
||||
@@ -420,7 +404,7 @@ export function UserBulkUploadModal({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={downloadTemplate}
|
||||
onClick={downloadUserTemplate}
|
||||
className="gap-2"
|
||||
>
|
||||
<Download size={14} />
|
||||
@@ -626,15 +610,71 @@ export function UserBulkUploadModal({
|
||||
) : (
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="flex items-center gap-4 p-4 rounded-lg bg-muted/30 border">
|
||||
<div className="flex-1 text-center">
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{successCount}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground uppercase">
|
||||
{t("ui.common.success", "성공")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-px h-10 bg-border" />
|
||||
{results.some((r) => r.success && r.status === "created") && (
|
||||
<>
|
||||
<div className="flex-1 text-center">
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{
|
||||
results.filter(
|
||||
(r) => r.success && r.status === "created",
|
||||
).length
|
||||
}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground uppercase">
|
||||
{t("ui.common.status.new", "신규")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-px h-10 bg-border" />
|
||||
</>
|
||||
)}
|
||||
{results.some((r) => r.success && r.status === "updated") && (
|
||||
<>
|
||||
<div className="flex-1 text-center">
|
||||
<div className="text-2xl font-bold text-blue-600">
|
||||
{
|
||||
results.filter(
|
||||
(r) => r.success && r.status === "updated",
|
||||
).length
|
||||
}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground uppercase">
|
||||
{t("ui.common.status.updated", "수정")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-px h-10 bg-border" />
|
||||
</>
|
||||
)}
|
||||
{results.some((r) => r.success && r.status === "unchanged") && (
|
||||
<>
|
||||
<div className="flex-1 text-center">
|
||||
<div className="text-2xl font-bold text-slate-500">
|
||||
{
|
||||
results.filter(
|
||||
(r) => r.success && r.status === "unchanged",
|
||||
).length
|
||||
}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground uppercase">
|
||||
{t("ui.common.status.unchanged", "동일")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-px h-10 bg-border" />
|
||||
</>
|
||||
)}
|
||||
{!results.some((r) => r.success && r.status) &&
|
||||
successCount > 0 && (
|
||||
<>
|
||||
<div className="flex-1 text-center">
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{successCount}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground uppercase">
|
||||
{t("ui.common.success", "성공")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-px h-10 bg-border" />
|
||||
</>
|
||||
)}
|
||||
<div className="flex-1 text-center">
|
||||
<div className="text-2xl font-bold text-destructive">
|
||||
{failCount}
|
||||
@@ -664,7 +704,60 @@ export function UserBulkUploadModal({
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate">{r.email}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="font-medium truncate">{r.email}</div>
|
||||
{r.success && r.status === "created" && (
|
||||
<span className="px-1.5 py-0.5 rounded-full bg-green-100 text-green-700 text-[10px] font-bold">
|
||||
{t("ui.common.status.new", "신규")}
|
||||
</span>
|
||||
)}
|
||||
{r.success && r.status === "updated" && (
|
||||
<span className="px-1.5 py-0.5 rounded-full bg-blue-100 text-blue-700 text-[10px] font-bold">
|
||||
{t("ui.common.status.updated", "수정")}
|
||||
</span>
|
||||
)}
|
||||
{r.success && r.status === "unchanged" && (
|
||||
<span className="px-1.5 py-0.5 rounded-full bg-slate-100 text-slate-600 text-[10px] font-bold">
|
||||
{t("ui.common.status.unchanged", "동일")}
|
||||
</span>
|
||||
)}
|
||||
{r.success && !r.status && (
|
||||
<span className="px-1.5 py-0.5 rounded-full bg-green-100 text-green-700 text-[10px] font-bold">
|
||||
{t("ui.common.success", "성공")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{r.success && r.status === "updated" && (
|
||||
<div className="mt-1 text-[10px] text-muted-foreground flex flex-wrap gap-1 items-center">
|
||||
<span className="font-medium">
|
||||
{t(
|
||||
"ui.admin.users.bulk.modified_fields",
|
||||
"수정 항목:",
|
||||
)}
|
||||
</span>
|
||||
{r.modifiedFields &&
|
||||
r.modifiedFields.length > 0 &&
|
||||
r.modifiedFields.map((field) => (
|
||||
<span
|
||||
key={field}
|
||||
className="px-1 py-0.5 rounded bg-blue-50 text-blue-600 border border-blue-100"
|
||||
>
|
||||
{t(
|
||||
`ui.admin.users.field.${field.toLowerCase()}`,
|
||||
field,
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{r.success && r.status === "unchanged" && (
|
||||
<div className="mt-1 text-[10px] text-muted-foreground italic">
|
||||
{t(
|
||||
"ui.admin.users.bulk.no_changes",
|
||||
"기존 데이터와 동일 (변경 사항 없음)",
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!r.success && (
|
||||
<div className="text-xs text-destructive">
|
||||
{r.message}
|
||||
|
||||
@@ -97,7 +97,7 @@ test@test.com,Test,local-tenant-id,missing-slug,Missing Tenant,COMPANY,parent-sl
|
||||
});
|
||||
});
|
||||
|
||||
it("should preserve exported user_id for UUID based restore", () => {
|
||||
it("should ignore exported user_id during user CSV import", () => {
|
||||
const csv = `user_id,email,name,tenant_id,tenant_slug
|
||||
9f8cc1b1-af8d-45d4-946c-924a529c2556,restore@test.com,Restore User,tenant-id,restore-tenant`;
|
||||
|
||||
@@ -105,12 +105,13 @@ test@test.com,Test,local-tenant-id,missing-slug,Missing Tenant,COMPANY,parent-sl
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
userId: "9f8cc1b1-af8d-45d4-946c-924a529c2556",
|
||||
email: "restore@test.com",
|
||||
name: "Restore User",
|
||||
tenantId: "tenant-id",
|
||||
tenantSlug: "restore-tenant",
|
||||
});
|
||||
expect(result[0]).not.toHaveProperty("id");
|
||||
expect(result[0]).not.toHaveProperty("uuid");
|
||||
});
|
||||
|
||||
it("should parse one nullable additional appointment from numbered columns", () => {
|
||||
|
||||
@@ -28,9 +28,7 @@ export function parseUserCSV(text: string): BulkUserItem[] {
|
||||
const value = values[index];
|
||||
if (value === undefined || value === "") continue;
|
||||
|
||||
if (header === "user_id") {
|
||||
item.userId = value;
|
||||
} else if (header === "email") {
|
||||
if (header === "email") {
|
||||
item.email = value;
|
||||
} else if (header === "name") {
|
||||
item.name = value;
|
||||
|
||||
@@ -726,7 +726,6 @@ export type BulkUserAppointment = {
|
||||
};
|
||||
|
||||
export type BulkUserItem = {
|
||||
userId?: string;
|
||||
email: string;
|
||||
loginId?: string;
|
||||
name: string;
|
||||
@@ -764,6 +763,7 @@ export type BulkUserResult = {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
userId?: string;
|
||||
modifiedFields?: string[];
|
||||
};
|
||||
|
||||
export type BulkUserResponse = {
|
||||
|
||||
Reference in New Issue
Block a user