forked from baron/baron-sso
Merge pull request 'feature/rbac-simplification-and-remove-dev-switcher' (#997) from feature/rbac-simplification-and-remove-dev-switcher into dev
Reviewed-on: baron/baron-sso#997
This commit is contained in:
@@ -76,6 +76,7 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "../../../components/ui/table";
|
} from "../../../components/ui/table";
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from "../../../components/ui/tabs";
|
||||||
import { toast } from "../../../components/ui/use-toast";
|
import { toast } from "../../../components/ui/use-toast";
|
||||||
import type { UserProfileResponse } from "../../../lib/adminApi";
|
import type { UserProfileResponse } from "../../../lib/adminApi";
|
||||||
import {
|
import {
|
||||||
@@ -84,6 +85,8 @@ import {
|
|||||||
fetchMe,
|
fetchMe,
|
||||||
fetchTenants,
|
fetchTenants,
|
||||||
importTenantsCSV,
|
importTenantsCSV,
|
||||||
|
type TenantImportDetail,
|
||||||
|
type TenantImportResult,
|
||||||
type TenantSummary,
|
type TenantSummary,
|
||||||
updateTenant,
|
updateTenant,
|
||||||
} from "../../../lib/adminApi";
|
} from "../../../lib/adminApi";
|
||||||
@@ -285,6 +288,22 @@ function TenantListPage() {
|
|||||||
Record<number, string>
|
Record<number, string>
|
||||||
>({});
|
>({});
|
||||||
const [previewOpen, setPreviewOpen] = React.useState(false);
|
const [previewOpen, setPreviewOpen] = React.useState(false);
|
||||||
|
const [importResult, setImportResult] =
|
||||||
|
React.useState<TenantImportResult | null>(null);
|
||||||
|
const [importResultOpen, setImportResultOpen] = React.useState(false);
|
||||||
|
const [importResultFilter, setImportResultFilter] = React.useState<
|
||||||
|
"all" | "created" | "updated" | "failed" | "skipped"
|
||||||
|
>("all");
|
||||||
|
|
||||||
|
const filteredImportDetails = React.useMemo(() => {
|
||||||
|
if (!importResult) return [];
|
||||||
|
if (importResultFilter === "all") return importResult.details;
|
||||||
|
if (importResultFilter === "failed")
|
||||||
|
return importResult.details.filter((d: TenantImportDetail) => !d.success);
|
||||||
|
return importResult.details.filter(
|
||||||
|
(d: TenantImportDetail) => d.action === importResultFilter,
|
||||||
|
);
|
||||||
|
}, [importResult, importResultFilter]);
|
||||||
const [selectedBulkStatus, setSelectedBulkStatus] = React.useState("");
|
const [selectedBulkStatus, setSelectedBulkStatus] = React.useState("");
|
||||||
const _tenantTableScrollRef = React.useRef<HTMLDivElement | null>(null);
|
const _tenantTableScrollRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
@@ -387,6 +406,8 @@ function TenantListPage() {
|
|||||||
const importMutation = useMutation({
|
const importMutation = useMutation({
|
||||||
mutationFn: (file: File) => importTenantsCSV(file),
|
mutationFn: (file: File) => importTenantsCSV(file),
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
|
setImportResult(result);
|
||||||
|
setImportResultOpen(true);
|
||||||
setImportMessage(
|
setImportMessage(
|
||||||
t(
|
t(
|
||||||
"msg.admin.tenants.import_result",
|
"msg.admin.tenants.import_result",
|
||||||
@@ -1006,6 +1027,194 @@ function TenantListPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Dialog open={importResultOpen} onOpenChange={setImportResultOpen}>
|
||||||
|
<DialogContent className="max-w-5xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{t(
|
||||||
|
"ui.admin.tenants.import_result.title",
|
||||||
|
"가져오기 결과 리포트",
|
||||||
|
)}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{importResult && (
|
||||||
|
<div className="grid grid-cols-4 gap-4 py-4">
|
||||||
|
<div className="flex flex-col items-center rounded-lg border bg-muted/30 p-3 shadow-sm">
|
||||||
|
<span className="text-[10px] font-bold tracking-wider text-muted-foreground uppercase">
|
||||||
|
Total
|
||||||
|
</span>
|
||||||
|
<span className="text-2xl font-bold">
|
||||||
|
{importResult.details.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center rounded-lg border border-emerald-500/20 bg-emerald-500/5 p-3 shadow-sm">
|
||||||
|
<span className="text-[10px] font-bold tracking-wider text-emerald-600 dark:text-emerald-400 uppercase">
|
||||||
|
Created
|
||||||
|
</span>
|
||||||
|
<span className="text-2xl font-bold text-emerald-600 dark:text-emerald-400">
|
||||||
|
{importResult.created}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center rounded-lg border border-amber-500/20 bg-amber-500/5 p-3 shadow-sm">
|
||||||
|
<span className="text-[10px] font-bold tracking-wider text-amber-600 dark:text-amber-400 uppercase">
|
||||||
|
Updated
|
||||||
|
</span>
|
||||||
|
<span className="text-2xl font-bold text-amber-600 dark:text-amber-400">
|
||||||
|
{importResult.updated}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center rounded-lg border border-destructive/20 bg-destructive/5 p-3 shadow-sm">
|
||||||
|
<span className="text-[10px] font-bold tracking-wider text-destructive uppercase">
|
||||||
|
Failed
|
||||||
|
</span>
|
||||||
|
<span className="text-2xl font-bold text-destructive">
|
||||||
|
{importResult.failed}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Tabs
|
||||||
|
value={importResultFilter}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setImportResultFilter(
|
||||||
|
v as "all" | "created" | "updated" | "failed" | "skipped",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<TabsList className="grid h-11 w-full grid-cols-5 bg-muted/50 p-1">
|
||||||
|
<TabsTrigger
|
||||||
|
value="all"
|
||||||
|
className="text-xs font-bold data-[state=active]:bg-background data-[state=active]:text-primary data-[state=active]:shadow-sm"
|
||||||
|
>
|
||||||
|
ALL
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value="created"
|
||||||
|
className="text-xs font-bold data-[state=active]:bg-background data-[state=active]:text-emerald-600 data-[state=active]:shadow-sm dark:data-[state=active]:text-emerald-400"
|
||||||
|
>
|
||||||
|
CREATED
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value="updated"
|
||||||
|
className="text-xs font-bold data-[state=active]:bg-background data-[state=active]:text-amber-600 data-[state=active]:shadow-sm dark:data-[state=active]:text-amber-400"
|
||||||
|
>
|
||||||
|
UPDATED
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value="failed"
|
||||||
|
className="text-xs font-bold data-[state=active]:bg-background data-[state=active]:text-destructive data-[state=active]:shadow-sm"
|
||||||
|
>
|
||||||
|
FAILED
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value="skipped"
|
||||||
|
className="text-xs font-bold data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm"
|
||||||
|
>
|
||||||
|
SKIPPED
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<div className="max-h-[50vh] overflow-auto rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader className={commonStickyTableHeaderClass}>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[72px]">
|
||||||
|
{t("ui.common.row", "행")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>
|
||||||
|
{t("ui.admin.tenants.table.name", "NAME")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>
|
||||||
|
{t("ui.admin.tenants.table.slug", "SLUG")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[120px]">
|
||||||
|
{t("ui.admin.tenants.import_result.status", "상태")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>
|
||||||
|
{t("ui.admin.tenants.import_result.message", "상세 내용")}
|
||||||
|
</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filteredImportDetails.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={5}
|
||||||
|
className="h-24 text-center text-muted-foreground"
|
||||||
|
>
|
||||||
|
{t("ui.common.no_results", "표시할 결과가 없습니다.")}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
filteredImportDetails.map((detail: TenantImportDetail) => (
|
||||||
|
<TableRow key={detail.row}>
|
||||||
|
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||||
|
{detail.row}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
{detail.name}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">
|
||||||
|
{detail.slug}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
detail.action === "created"
|
||||||
|
? "success"
|
||||||
|
: detail.action === "updated"
|
||||||
|
? "warning"
|
||||||
|
: detail.action === "skipped"
|
||||||
|
? "outline"
|
||||||
|
: "destructive"
|
||||||
|
}
|
||||||
|
className="w-full justify-center text-[10px]"
|
||||||
|
>
|
||||||
|
{detail.action.toUpperCase()}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs">
|
||||||
|
{detail.message}
|
||||||
|
{detail.modifiedFields &&
|
||||||
|
detail.modifiedFields.length > 0 && (
|
||||||
|
<div className="mt-1 flex flex-wrap gap-1">
|
||||||
|
<span className="mr-1 text-[10px] text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"ui.admin.tenants.import_result.modified",
|
||||||
|
"수정됨:",
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{detail.modifiedFields.map((field: string) => (
|
||||||
|
<Badge
|
||||||
|
key={field}
|
||||||
|
variant="outline"
|
||||||
|
className="h-4 bg-muted px-1 text-[9px] font-normal"
|
||||||
|
>
|
||||||
|
{field}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button onClick={() => setImportResultOpen(false)}>
|
||||||
|
{t("ui.common.confirm", "확인")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
<Dialog open={previewOpen} onOpenChange={setPreviewOpen}>
|
<Dialog open={previewOpen} onOpenChange={setPreviewOpen}>
|
||||||
<DialogContent className="max-w-5xl">
|
<DialogContent className="max-w-5xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
|
|||||||
@@ -70,11 +70,22 @@ export type TenantUpdateRequest = {
|
|||||||
config?: Record<string, unknown>;
|
config?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TenantImportDetail = {
|
||||||
|
row: number;
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
success: boolean;
|
||||||
|
action: "created" | "updated" | "failed" | "skipped";
|
||||||
|
message: string;
|
||||||
|
modifiedFields?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
export type TenantImportResult = {
|
export type TenantImportResult = {
|
||||||
created: number;
|
created: number;
|
||||||
updated: number;
|
updated: number;
|
||||||
failed: number;
|
failed: number;
|
||||||
errors: string[];
|
errors: string[];
|
||||||
|
details: TenantImportDetail[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ApiKeySummary = {
|
export type ApiKeySummary = {
|
||||||
|
|||||||
@@ -684,7 +684,22 @@ test.describe("Tenants Management", () => {
|
|||||||
importRequested = true;
|
importRequested = true;
|
||||||
importBody = route.request().postData() ?? "";
|
importBody = route.request().postData() ?? "";
|
||||||
return route.fulfill({
|
return route.fulfill({
|
||||||
json: { created: 0, updated: 1, failed: 0, errors: [] },
|
json: {
|
||||||
|
created: 0,
|
||||||
|
updated: 1,
|
||||||
|
failed: 0,
|
||||||
|
errors: [],
|
||||||
|
details: [
|
||||||
|
{
|
||||||
|
row: 2,
|
||||||
|
name: "Tenant Alpha",
|
||||||
|
slug: "tenant-alpha",
|
||||||
|
success: true,
|
||||||
|
action: "updated",
|
||||||
|
message: "updated successfully",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
headers,
|
headers,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -774,9 +789,12 @@ test.describe("Tenants Management", () => {
|
|||||||
);
|
);
|
||||||
await page.getByTestId("tenant-import-confirm-btn").click();
|
await page.getByTestId("tenant-import-confirm-btn").click();
|
||||||
|
|
||||||
await expect(page.getByTestId("tenant-import-result")).toContainText(
|
const resultDialog = page.getByRole("dialog").filter({
|
||||||
/갱신 1|Updated 1/i,
|
hasText: /가져오기 결과 리포트|Import Result Report/i,
|
||||||
);
|
});
|
||||||
|
await expect(resultDialog).toBeVisible({ timeout: 15000 });
|
||||||
|
await expect(resultDialog).toContainText(/Updated|갱신/i);
|
||||||
|
await expect(resultDialog).toContainText("1");
|
||||||
expect(importRequested).toBe(true);
|
expect(importRequested).toBe(true);
|
||||||
expect(importBody).toContain('filename="tenants.csv"');
|
expect(importBody).toContain('filename="tenants.csv"');
|
||||||
if (browserName !== "webkit") {
|
if (browserName !== "webkit") {
|
||||||
@@ -799,7 +817,30 @@ test.describe("Tenants Management", () => {
|
|||||||
if (url.includes("/import")) {
|
if (url.includes("/import")) {
|
||||||
importBody = route.request().postData() ?? "";
|
importBody = route.request().postData() ?? "";
|
||||||
return route.fulfill({
|
return route.fulfill({
|
||||||
json: { created: 2, updated: 0, failed: 0, errors: [] },
|
json: {
|
||||||
|
created: 2,
|
||||||
|
updated: 0,
|
||||||
|
failed: 0,
|
||||||
|
errors: [],
|
||||||
|
details: [
|
||||||
|
{
|
||||||
|
row: 2,
|
||||||
|
name: "Child A",
|
||||||
|
slug: "child-a",
|
||||||
|
success: true,
|
||||||
|
action: "created",
|
||||||
|
message: "created successfully",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
row: 3,
|
||||||
|
name: "Child B",
|
||||||
|
slug: "child-b",
|
||||||
|
success: true,
|
||||||
|
action: "created",
|
||||||
|
message: "created successfully",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
headers,
|
headers,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"maps"
|
"maps"
|
||||||
|
"reflect"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -106,11 +107,22 @@ func pageTenantsByCursor(tenants []domain.Tenant, limit int, cursorRaw string) (
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type tenantImportDetail struct {
|
||||||
|
Row int `json:"row"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Action string `json:"action"` // "created", "updated", "failed", "skipped"
|
||||||
|
Message string `json:"message"` // Detailed error or success message
|
||||||
|
ModifiedFields []string `json:"modifiedFields"` // List of fields changed during update
|
||||||
|
}
|
||||||
|
|
||||||
type tenantImportResult struct {
|
type tenantImportResult struct {
|
||||||
Created int `json:"created"`
|
Created int `json:"created"`
|
||||||
Updated int `json:"updated"`
|
Updated int `json:"updated"`
|
||||||
Failed int `json:"failed"`
|
Failed int `json:"failed"`
|
||||||
Errors []string `json:"errors"`
|
Errors []string `json:"errors"`
|
||||||
|
Details []tenantImportDetail `json:"details"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type tenantDomainConflict struct {
|
type tenantDomainConflict struct {
|
||||||
@@ -561,32 +573,64 @@ func (h *TenantHandler) ImportTenantsCSV(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
result := tenantImportResult{Errors: make([]string, 0)}
|
result := tenantImportResult{
|
||||||
|
Errors: make([]string, 0),
|
||||||
|
Details: make([]tenantImportDetail, 0, len(records)),
|
||||||
|
}
|
||||||
for i, record := range records {
|
for i, record := range records {
|
||||||
rowNumber := i + 2
|
rowNumber := i + 2
|
||||||
|
detail := tenantImportDetail{
|
||||||
|
Row: rowNumber,
|
||||||
|
Slug: record.Slug,
|
||||||
|
Name: record.Name,
|
||||||
|
}
|
||||||
|
|
||||||
if record.ParentTenantID == nil && record.ParentTenantSlug != "" {
|
if record.ParentTenantID == nil && record.ParentTenantSlug != "" {
|
||||||
parentID := tenantIDBySlug[strings.ToLower(record.ParentTenantSlug)]
|
parentID := tenantIDBySlug[strings.ToLower(record.ParentTenantSlug)]
|
||||||
if parentID == "" {
|
if parentID == "" {
|
||||||
result.Failed++
|
result.Failed++
|
||||||
result.Errors = append(result.Errors, fmt.Sprintf("row %d: parent tenant slug not found: %s", rowNumber, record.ParentTenantSlug))
|
msg := fmt.Sprintf("row %d: parent tenant slug not found: %s", rowNumber, record.ParentTenantSlug)
|
||||||
|
result.Errors = append(result.Errors, msg)
|
||||||
|
detail.Success = false
|
||||||
|
detail.Action = "failed"
|
||||||
|
detail.Message = msg
|
||||||
|
result.Details = append(result.Details, detail)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
record.ParentTenantID = &parentID
|
record.ParentTenantID = &parentID
|
||||||
}
|
}
|
||||||
if record.TenantID != "" || (h.DB != nil && record.Slug != "") {
|
if record.TenantID != "" || (h.DB != nil && record.Slug != "") {
|
||||||
tenant, updated, err := h.upsertTenantCSVRecord(c, record)
|
tenant, modifiedFields, err := h.upsertTenantCSVRecord(c, record)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
result.Failed++
|
result.Failed++
|
||||||
result.Errors = append(result.Errors, fmt.Sprintf("row %d: %s", rowNumber, err.Error()))
|
msg := fmt.Sprintf("row %d: %s", rowNumber, err.Error())
|
||||||
|
result.Errors = append(result.Errors, msg)
|
||||||
|
detail.Success = false
|
||||||
|
detail.Action = "failed"
|
||||||
|
detail.Message = msg
|
||||||
|
result.Details = append(result.Details, detail)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if updated {
|
if tenant != nil {
|
||||||
tenantIDBySlug[strings.ToLower(record.Slug)] = tenant.ID
|
if len(modifiedFields) > 0 {
|
||||||
result.Updated++
|
tenantIDBySlug[strings.ToLower(record.Slug)] = tenant.ID
|
||||||
if h.Worksmobile != nil {
|
result.Updated++
|
||||||
_ = h.Worksmobile.EnqueueTenantUpsertIfInScope(c.Context(), *tenant)
|
detail.Success = true
|
||||||
|
detail.Action = "updated"
|
||||||
|
detail.ModifiedFields = modifiedFields
|
||||||
|
if h.Worksmobile != nil {
|
||||||
|
_ = h.Worksmobile.EnqueueTenantUpsertIfInScope(c.Context(), *tenant)
|
||||||
|
}
|
||||||
|
result.Details = append(result.Details, detail)
|
||||||
|
continue
|
||||||
|
} else {
|
||||||
|
// No changes, skip
|
||||||
|
detail.Success = true
|
||||||
|
detail.Action = "skipped"
|
||||||
|
detail.Message = "no changes detected"
|
||||||
|
result.Details = append(result.Details, detail)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -598,19 +642,32 @@ func (h *TenantHandler) ImportTenantsCSV(c *fiber.Ctx) error {
|
|||||||
tenant, err := h.createTenantCSVRecord(c, record, recordCreatorID)
|
tenant, err := h.createTenantCSVRecord(c, record, recordCreatorID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
result.Failed++
|
result.Failed++
|
||||||
result.Errors = append(result.Errors, fmt.Sprintf("row %d: %s", rowNumber, err.Error()))
|
msg := fmt.Sprintf("row %d: %s", rowNumber, err.Error())
|
||||||
|
result.Errors = append(result.Errors, msg)
|
||||||
|
detail.Success = false
|
||||||
|
detail.Action = "failed"
|
||||||
|
detail.Message = msg
|
||||||
|
result.Details = append(result.Details, detail)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if tenant == nil {
|
if tenant == nil {
|
||||||
result.Failed++
|
result.Failed++
|
||||||
result.Errors = append(result.Errors, fmt.Sprintf("row %d: tenant creation returned empty result", rowNumber))
|
msg := fmt.Sprintf("row %d: tenant creation returned empty result", rowNumber)
|
||||||
|
result.Errors = append(result.Errors, msg)
|
||||||
|
detail.Success = false
|
||||||
|
detail.Action = "failed"
|
||||||
|
detail.Message = msg
|
||||||
|
result.Details = append(result.Details, detail)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
tenantIDBySlug[strings.ToLower(record.Slug)] = tenant.ID
|
tenantIDBySlug[strings.ToLower(record.Slug)] = tenant.ID
|
||||||
result.Created++
|
result.Created++
|
||||||
|
detail.Success = true
|
||||||
|
detail.Action = "created"
|
||||||
if h.Worksmobile != nil {
|
if h.Worksmobile != nil {
|
||||||
_ = h.Worksmobile.EnqueueTenantUpsertIfInScope(c.Context(), *tenant)
|
_ = h.Worksmobile.EnqueueTenantUpsertIfInScope(c.Context(), *tenant)
|
||||||
}
|
}
|
||||||
|
result.Details = append(result.Details, detail)
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.JSON(result)
|
return c.JSON(result)
|
||||||
@@ -1389,12 +1446,12 @@ func (h *TenantHandler) replaceTenantDomains(ctx context.Context, tenantID strin
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *TenantHandler) upsertTenantCSVRecord(c *fiber.Ctx, record tenantCSVRecord) (*domain.Tenant, bool, error) {
|
func (h *TenantHandler) upsertTenantCSVRecord(c *fiber.Ctx, record tenantCSVRecord) (*domain.Tenant, []string, error) {
|
||||||
if h.DB == nil {
|
if h.DB == nil {
|
||||||
if record.TenantID != "" {
|
if record.TenantID != "" {
|
||||||
return nil, false, errors.New("database not available for tenant update")
|
return nil, nil, errors.New("database not available for tenant update")
|
||||||
}
|
}
|
||||||
return nil, false, nil
|
return nil, nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var tenant domain.Tenant
|
var tenant domain.Tenant
|
||||||
@@ -1407,42 +1464,85 @@ func (h *TenantHandler) upsertTenantCSVRecord(c *fiber.Ctx, record tenantCSVReco
|
|||||||
}
|
}
|
||||||
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return nil, false, nil
|
return nil, nil, nil
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, false, err
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
modifiedFields := []string{}
|
||||||
|
if tenant.Name != record.Name {
|
||||||
|
tenant.Name = record.Name
|
||||||
|
modifiedFields = append(modifiedFields, "Name")
|
||||||
|
}
|
||||||
|
if tenant.Type != record.Type {
|
||||||
|
tenant.Type = record.Type
|
||||||
|
modifiedFields = append(modifiedFields, "Type")
|
||||||
|
}
|
||||||
|
if record.ParentTenantID != nil {
|
||||||
|
oldParentID := ""
|
||||||
|
if tenant.ParentID != nil {
|
||||||
|
oldParentID = *tenant.ParentID
|
||||||
|
}
|
||||||
|
if oldParentID != *record.ParentTenantID {
|
||||||
|
tenant.ParentID = record.ParentTenantID
|
||||||
|
modifiedFields = append(modifiedFields, "ParentID")
|
||||||
|
}
|
||||||
|
} else if tenant.ParentID != nil {
|
||||||
|
tenant.ParentID = nil
|
||||||
|
modifiedFields = append(modifiedFields, "ParentID")
|
||||||
|
}
|
||||||
|
|
||||||
|
if tenant.Slug != record.Slug {
|
||||||
|
tenant.Slug = record.Slug
|
||||||
|
modifiedFields = append(modifiedFields, "Slug")
|
||||||
|
}
|
||||||
|
if tenant.Description != record.Memo {
|
||||||
|
tenant.Description = record.Memo
|
||||||
|
modifiedFields = append(modifiedFields, "Description")
|
||||||
}
|
}
|
||||||
|
|
||||||
tenant.Name = record.Name
|
|
||||||
tenant.Type = record.Type
|
|
||||||
tenant.ParentID = record.ParentTenantID
|
|
||||||
tenant.Slug = record.Slug
|
|
||||||
tenant.Description = record.Memo
|
|
||||||
if tenant.Status == "" {
|
if tenant.Status == "" {
|
||||||
tenant.Status = domain.TenantStatusActive
|
tenant.Status = domain.TenantStatusActive
|
||||||
}
|
}
|
||||||
mergedConfig, changedConfig, err := mergeTenantCSVRecordConfig(tenant.Config, record)
|
mergedConfig, changedConfig, err := mergeTenantCSVRecordConfig(tenant.Config, record)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, false, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
if changedConfig {
|
if changedConfig {
|
||||||
tenant.Config = mergedConfig
|
tenant.Config = mergedConfig
|
||||||
|
modifiedFields = append(modifiedFields, "Config")
|
||||||
|
}
|
||||||
|
|
||||||
|
existingDomains := make([]string, len(tenant.Domains))
|
||||||
|
for i, d := range tenant.Domains {
|
||||||
|
existingDomains[i] = d.Domain
|
||||||
|
}
|
||||||
|
sort.Strings(existingDomains)
|
||||||
|
newDomains := append([]string(nil), record.Domains...)
|
||||||
|
sort.Strings(newDomains)
|
||||||
|
if !reflect.DeepEqual(existingDomains, newDomains) {
|
||||||
|
modifiedFields = append(modifiedFields, "Domains")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(modifiedFields) == 0 {
|
||||||
|
return &tenant, nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.DB.Save(&tenant).Error; err != nil {
|
if err := h.DB.Save(&tenant).Error; err != nil {
|
||||||
return nil, false, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
if err := h.DB.Delete(&domain.TenantDomain{}, "tenant_id = ?", tenant.ID).Error; err != nil {
|
if err := h.DB.Delete(&domain.TenantDomain{}, "tenant_id = ?", tenant.ID).Error; err != nil {
|
||||||
return nil, false, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
repo := repository.NewTenantRepository(h.DB)
|
repo := repository.NewTenantRepository(h.DB)
|
||||||
for _, domainName := range record.Domains {
|
for _, domainName := range record.Domains {
|
||||||
if err := repo.AddDomain(c.Context(), tenant.ID, domainName, true); err != nil {
|
if err := repo.AddDomain(c.Context(), tenant.ID, domainName, true); err != nil {
|
||||||
return nil, false, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return &tenant, true, nil
|
return &tenant, modifiedFields, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *TenantHandler) createTenantCSVRecord(c *fiber.Ctx, record tenantCSVRecord, creatorID string) (*domain.Tenant, error) {
|
func (h *TenantHandler) createTenantCSVRecord(c *fiber.Ctx, record tenantCSVRecord, creatorID string) (*domain.Tenant, error) {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ subtitle = "View administrator activity history."
|
|||||||
subtitle = "View developer activity history within the current app scope."
|
subtitle = "View developer activity history within the current app scope."
|
||||||
|
|
||||||
[ui.common]
|
[ui.common]
|
||||||
|
no_results = "No results to display."
|
||||||
apply = "Apply"
|
apply = "Apply"
|
||||||
actions = "Actions"
|
actions = "Actions"
|
||||||
add = "Add"
|
add = "Add"
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ subtitle = "관리자 작업 이력을 조회합니다."
|
|||||||
subtitle = "현재 앱 범위의 개발자 작업 이력을 조회합니다."
|
subtitle = "현재 앱 범위의 개발자 작업 이력을 조회합니다."
|
||||||
|
|
||||||
[ui.common]
|
[ui.common]
|
||||||
|
no_results = "표시할 결과가 없습니다."
|
||||||
apply = "적용"
|
apply = "적용"
|
||||||
actions = "액션"
|
actions = "액션"
|
||||||
add = "추가"
|
add = "추가"
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ subtitle = ""
|
|||||||
subtitle = ""
|
subtitle = ""
|
||||||
|
|
||||||
[ui.common]
|
[ui.common]
|
||||||
|
no_results = ""
|
||||||
apply = "Apply"
|
apply = "Apply"
|
||||||
actions = ""
|
actions = ""
|
||||||
add = ""
|
add = ""
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export const commonBadgeVariantClasses = {
|
|||||||
"border-transparent bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300",
|
"border-transparent bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300",
|
||||||
warning:
|
warning:
|
||||||
"border-transparent bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-200",
|
"border-transparent bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-200",
|
||||||
|
destructive:
|
||||||
|
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
|
||||||
info: "border-transparent bg-blue-500 text-white hover:bg-blue-500/90",
|
info: "border-transparent bg-blue-500 text-white hover:bg-blue-500/90",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|||||||
@@ -1179,6 +1179,12 @@ pick = "Select parent scope"
|
|||||||
description = ""
|
description = ""
|
||||||
title = "Domain conflict"
|
title = "Domain conflict"
|
||||||
|
|
||||||
|
[ui.admin.tenants.import_result]
|
||||||
|
message = "Message"
|
||||||
|
modified = "Modified:"
|
||||||
|
status = "Status"
|
||||||
|
title = "Import Result Report"
|
||||||
|
|
||||||
[ui.admin.tenants.import_preview]
|
[ui.admin.tenants.import_preview]
|
||||||
candidates = "Candidates"
|
candidates = "Candidates"
|
||||||
confirm = "Run import"
|
confirm = "Run import"
|
||||||
@@ -1323,6 +1329,12 @@ total = "Total"
|
|||||||
total_label = "Total"
|
total_label = "Total"
|
||||||
view_profile = "View Profile"
|
view_profile = "View Profile"
|
||||||
|
|
||||||
|
[ui.admin.tenants.import_result]
|
||||||
|
message = "Message"
|
||||||
|
modified = "Modified:"
|
||||||
|
status = "Status"
|
||||||
|
title = "Import Result Report"
|
||||||
|
|
||||||
[ui.admin.tenants.import_preview]
|
[ui.admin.tenants.import_preview]
|
||||||
candidates = "Candidates"
|
candidates = "Candidates"
|
||||||
confirm = "Confirm Import"
|
confirm = "Confirm Import"
|
||||||
|
|||||||
@@ -375,6 +375,12 @@ view_org_chart = "전체 조직도 보기"
|
|||||||
description = ""
|
description = ""
|
||||||
title = "도메인 충돌"
|
title = "도메인 충돌"
|
||||||
|
|
||||||
|
[ui.admin.tenants.import_result]
|
||||||
|
message = "상세 내용"
|
||||||
|
modified = "수정됨:"
|
||||||
|
status = "상태"
|
||||||
|
title = "가져오기 결과 리포트"
|
||||||
|
|
||||||
[ui.admin.tenants.import_preview]
|
[ui.admin.tenants.import_preview]
|
||||||
candidates = "후보"
|
candidates = "후보"
|
||||||
confirm = "가져오기 실행"
|
confirm = "가져오기 실행"
|
||||||
@@ -1786,6 +1792,12 @@ total = "전체"
|
|||||||
total_label = "전체"
|
total_label = "전체"
|
||||||
view_profile = "상세 정보"
|
view_profile = "상세 정보"
|
||||||
|
|
||||||
|
[ui.admin.tenants.import_result]
|
||||||
|
message = "상세 내용"
|
||||||
|
modified = "수정됨:"
|
||||||
|
status = "상태"
|
||||||
|
title = "가져오기 결과 리포트"
|
||||||
|
|
||||||
[ui.admin.tenants.import_preview]
|
[ui.admin.tenants.import_preview]
|
||||||
candidates = "후보"
|
candidates = "후보"
|
||||||
confirm = "임포트 확정"
|
confirm = "임포트 확정"
|
||||||
|
|||||||
@@ -233,6 +233,12 @@ view_org_chart = ""
|
|||||||
description = ""
|
description = ""
|
||||||
title = ""
|
title = ""
|
||||||
|
|
||||||
|
[ui.admin.tenants.import_result]
|
||||||
|
message = ""
|
||||||
|
modified = ""
|
||||||
|
status = ""
|
||||||
|
title = ""
|
||||||
|
|
||||||
[ui.admin.tenants.import_preview]
|
[ui.admin.tenants.import_preview]
|
||||||
candidates = ""
|
candidates = ""
|
||||||
confirm = ""
|
confirm = ""
|
||||||
@@ -1747,6 +1753,12 @@ search_placeholder = ""
|
|||||||
title = ""
|
title = ""
|
||||||
tree_search_placeholder = ""
|
tree_search_placeholder = ""
|
||||||
|
|
||||||
|
[ui.admin.tenants.import_result]
|
||||||
|
message = ""
|
||||||
|
modified = ""
|
||||||
|
status = ""
|
||||||
|
title = ""
|
||||||
|
|
||||||
[ui.admin.tenants.import_preview]
|
[ui.admin.tenants.import_preview]
|
||||||
candidates = ""
|
candidates = ""
|
||||||
confirm = ""
|
confirm = ""
|
||||||
|
|||||||
Reference in New Issue
Block a user