1
0
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:
2026-06-01 17:48:39 +09:00
91 changed files with 2173 additions and 1268 deletions

View File

@@ -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}

View File

@@ -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", () => {

View File

@@ -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;