diff --git a/apps/api/prisma/migrations/20260903160000_org_business_field/migration.sql b/apps/api/prisma/migrations/20260903160000_org_business_field/migration.sql new file mode 100644 index 0000000..c5a10ac --- /dev/null +++ b/apps/api/prisma/migrations/20260903160000_org_business_field/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Organization" ADD COLUMN "businessField" TEXT; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 6560262..39a5d90 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -134,6 +134,8 @@ model Organization { id String @id @default(cuid()) name String slug String @unique + /// زمینه فعالیت سازمان — فهرست هم‌تراز صنایع دیدار + businessField String? baseCurrency String @default("IRR") enabledCurrencies String[] @default(["IRR", "USD", "EUR"]) createdAt DateTime @default(now()) diff --git a/apps/api/prisma/seed.ts b/apps/api/prisma/seed.ts index 8de69b9..9e624e4 100644 --- a/apps/api/prisma/seed.ts +++ b/apps/api/prisma/seed.ts @@ -34,6 +34,7 @@ async function main() { slug: 'demo-mento', baseCurrency: 'IRR', enabledCurrencies: ['IRR', 'USD', 'EUR'], + businessField: 'tech', users: { create: { email, @@ -60,6 +61,7 @@ async function main() { data: { baseCurrency: 'IRR', enabledCurrencies: ['IRR', 'USD', 'EUR'], + businessField: 'tech', }, }); for (const row of [ diff --git a/apps/api/src/ai/ai-ask.service.ts b/apps/api/src/ai/ai-ask.service.ts index 752350f..057ebb1 100644 --- a/apps/api/src/ai/ai-ask.service.ts +++ b/apps/api/src/ai/ai-ask.service.ts @@ -11,6 +11,7 @@ import { noteAccessWhere, ownerWhere, } from '../common/record-scope'; +import { BUSINESS_FIELD_LABELS, isBusinessField } from '@mento/shared'; import { AiService } from './ai.service'; type CatalogItem = AskMentoSourceDto; @@ -101,7 +102,11 @@ Rules: if (!person) throw new NotFoundException('شخص پیدا نشد'); } - const [notes, activities, deals, memories] = await Promise.all([ + const [org, notes, activities, deals, memories] = await Promise.all([ + this.prisma.organization.findUnique({ + where: { id: actor.organizationId }, + select: { name: true, businessField: true }, + }), this.prisma.note.findMany({ where: { organizationId: actor.organizationId, @@ -167,8 +172,13 @@ Rules: ]); const catalog: CatalogItem[] = []; + const field = + org?.businessField && isBusinessField(org.businessField) + ? BUSINESS_FIELD_LABELS[org.businessField] + : 'نامشخص'; const lines: string[] = [ `Today: ${new Date().toISOString()}`, + `Organization: ${org?.name ?? '—'} | business field: ${field}`, personId ? `Focus personId: ${personId}` : 'Scope: current user records', ]; diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index c267eb7..965e252 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -45,6 +45,7 @@ export class AuthService { data: { name: dto.organizationName, slug, + businessField: dto.businessField, users: { create: { email: dto.email.toLowerCase(), diff --git a/apps/api/src/auth/dto.ts b/apps/api/src/auth/dto.ts index 9420e97..0a3126b 100644 --- a/apps/api/src/auth/dto.ts +++ b/apps/api/src/auth/dto.ts @@ -1,4 +1,10 @@ -import { IsEmail, IsString, MinLength } from 'class-validator'; +import { + IsEmail, + IsIn, + IsString, + MinLength, +} from 'class-validator'; +import { BUSINESS_FIELDS } from '@mento/shared'; export class LoginDto { @IsEmail() @@ -24,4 +30,7 @@ export class RegisterOrganizationDto { @IsString() @MinLength(6) password!: string; + + @IsIn([...BUSINESS_FIELDS]) + businessField!: (typeof BUSINESS_FIELDS)[number]; } diff --git a/apps/api/src/organizations/dto.ts b/apps/api/src/organizations/dto.ts index 73abe49..dd5bc11 100644 --- a/apps/api/src/organizations/dto.ts +++ b/apps/api/src/organizations/dto.ts @@ -1,6 +1,7 @@ import { ArrayMinSize, IsArray, + IsIn, IsNumber, IsOptional, IsString, @@ -10,6 +11,7 @@ import { ValidateNested, } from 'class-validator'; import { Type } from 'class-transformer'; +import { BUSINESS_FIELDS } from '@mento/shared'; export class ExchangeRateInputDto { @IsString() @@ -42,3 +44,15 @@ export class UpdateOrgCurrenciesDto { @Type(() => ExchangeRateInputDto) rates?: ExchangeRateInputDto[]; } + +export class UpdateOrganizationDto { + @IsOptional() + @IsString() + @MinLength(2) + @MaxLength(120) + name?: string; + + @IsOptional() + @IsIn([...BUSINESS_FIELDS]) + businessField?: (typeof BUSINESS_FIELDS)[number]; +} diff --git a/apps/api/src/organizations/organizations.controller.ts b/apps/api/src/organizations/organizations.controller.ts index be4329c..a9a3145 100644 --- a/apps/api/src/organizations/organizations.controller.ts +++ b/apps/api/src/organizations/organizations.controller.ts @@ -3,7 +3,7 @@ import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { RolesGuard } from '../auth/roles.guard'; import { Roles } from '../auth/roles.decorator'; import { CurrentUser } from '../auth/current-user.decorator'; -import { UpdateOrgCurrenciesDto } from './dto'; +import { UpdateOrgCurrenciesDto, UpdateOrganizationDto } from './dto'; import { OrganizationsService } from './organizations.service'; @Controller('organizations') @@ -21,6 +21,15 @@ export class OrganizationsController { return this.organizationsService.catalog(); } + @Patch('current') + @Roles('owner', 'admin') + updateCurrent( + @CurrentUser() user: { organizationId: string }, + @Body() dto: UpdateOrganizationDto, + ) { + return this.organizationsService.updateProfile(user.organizationId, dto); + } + @Patch('current/currencies') @Roles('owner', 'admin') updateCurrencies( diff --git a/apps/api/src/organizations/organizations.service.ts b/apps/api/src/organizations/organizations.service.ts index 029b077..12901a1 100644 --- a/apps/api/src/organizations/organizations.service.ts +++ b/apps/api/src/organizations/organizations.service.ts @@ -7,7 +7,7 @@ import type { OrganizationDto, UpdateOrgCurrenciesRequest, } from '@mento/shared'; -import { CURRENCY_CATALOG } from '@mento/shared'; +import { CURRENCY_CATALOG, isBusinessField } from '@mento/shared'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { isKnownCurrency } from '../common/currency'; @@ -29,6 +29,27 @@ export class OrganizationsService { return this.toDto(org); } + async updateProfile( + organizationId: string, + dto: { name?: string; businessField?: string }, + ): Promise { + if (dto.businessField && !isBusinessField(dto.businessField)) { + throw new BadRequestException('زمینه فعالیت معتبر نیست'); + } + const name = dto.name?.trim(); + if (dto.name !== undefined && (!name || name.length < 2)) { + throw new BadRequestException('نام سازمان معتبر نیست'); + } + await this.prisma.organization.update({ + where: { id: organizationId }, + data: { + ...(name ? { name } : {}), + ...(dto.businessField ? { businessField: dto.businessField } : {}), + }, + }); + return this.getCurrent(organizationId); + } + async updateCurrencies( organizationId: string, dto: UpdateOrgCurrenciesRequest, @@ -130,6 +151,7 @@ export class OrganizationsService { id: string; name: string; slug: string; + businessField: string | null; baseCurrency: string; enabledCurrencies: string[]; exchangeRates: Array<{ @@ -142,6 +164,10 @@ export class OrganizationsService { id: org.id, name: org.name, slug: org.slug, + businessField: + org.businessField && isBusinessField(org.businessField) + ? org.businessField + : null, baseCurrency: org.baseCurrency, enabledCurrencies: org.enabledCurrencies, exchangeRates: org.exchangeRates.map((r) => ({ diff --git a/apps/web/src/lib/business-fields.ts b/apps/web/src/lib/business-fields.ts new file mode 100644 index 0000000..9e8c656 --- /dev/null +++ b/apps/web/src/lib/business-fields.ts @@ -0,0 +1,47 @@ +import type { BusinessField } from '@mento/shared'; + +export const BUSINESS_FIELDS: BusinessField[] = [ + 'education', + 'beauty', + 'real_estate', + 'construction', + 'advertising', + 'insurance', + 'consulting', + 'startup', + 'medical_devices', + 'travel', + 'contractor', + 'trading', + 'manufacturing', + 'personal', + 'tech', + 'legal', + 'repair', + 'retail', + 'jewelry', + 'other', +]; + +export const BUSINESS_FIELD_LABELS: Record = { + education: 'آموزشگاه و مؤسسات آموزشی', + beauty: 'کلینیک زیبایی و آرایشگاه', + real_estate: 'املاک و مشاورین مسکن', + construction: 'صنعت ساختمان', + advertising: 'تبلیغات و مشاوره کسب‌وکار', + insurance: 'بیمه', + consulting: 'شرکت‌های مشاور', + startup: 'استارتاپ و کسب‌وکار آنلاین', + medical_devices: 'تجهیزات پزشکی', + travel: 'گردشگری و مهاجرت', + contractor: 'پیمانکاری و پروژه‌ای', + trading: 'بازرگانی', + manufacturing: 'تولیدی و صنعتی', + personal: 'کسب‌وکار شخصی و خانگی', + tech: 'نرم‌افزار و سخت‌افزار', + legal: 'وکلا و مشاورین حقوقی', + repair: 'خدمات و تعمیرات', + retail: 'خرده‌فروشی', + jewelry: 'طلا و جواهر', + other: 'سایر', +}; diff --git a/apps/web/src/stores/auth.ts b/apps/web/src/stores/auth.ts index 42e85ab..5bbce01 100644 --- a/apps/web/src/stores/auth.ts +++ b/apps/web/src/stores/auth.ts @@ -49,6 +49,7 @@ export const useAuthStore = defineStore('auth', () => { fullName: string; email: string; password: string; + businessField: string; }) { const { data } = await api.post('/auth/register', payload); persist(data); diff --git a/apps/web/src/views/RegisterView.vue b/apps/web/src/views/RegisterView.vue index 15782f4..47de076 100644 --- a/apps/web/src/views/RegisterView.vue +++ b/apps/web/src/views/RegisterView.vue @@ -2,12 +2,14 @@ import { ref } from 'vue'; import { useRouter, RouterLink } from 'vue-router'; import { useAuthStore } from '@/stores/auth'; +import { BUSINESS_FIELDS, BUSINESS_FIELD_LABELS } from '@/lib/business-fields'; import axios from 'axios'; const auth = useAuthStore(); const router = useRouter(); const organizationName = ref(''); +const businessField = ref(''); const fullName = ref(''); const email = ref(''); const password = ref(''); @@ -20,6 +22,7 @@ async function submit() { try { await auth.register({ organizationName: organizationName.value, + businessField: businessField.value, fullName: fullName.value, email: email.value, password: password.value, @@ -47,13 +50,22 @@ async function submit() {

شروع با منتو

ساخت سازمان

-

اولین کاربر به‌عنوان مالک سازمان ثبت می‌شود.

+

اولین کاربر به‌عنوان مالک سازمان ثبت می‌شود. زمینه فعالیت کمک می‌کند منتو فضای کاری را با صنعت شما هم‌تراز کند.

+
+ + +
diff --git a/apps/web/src/views/TeamSettingsView.vue b/apps/web/src/views/TeamSettingsView.vue index daa8d1d..14dd447 100644 --- a/apps/web/src/views/TeamSettingsView.vue +++ b/apps/web/src/views/TeamSettingsView.vue @@ -5,16 +5,24 @@ import { api } from '@/lib/api'; import { useAuthStore } from '@/stores/auth'; import type { InviteUserRequest, + OrganizationDto, OrgUserDto, + UpdateOrganizationRequest, UserRole, } from '@mento/shared'; -import { USER_ROLE_LABELS, canManageUsers } from '@/lib/roles'; +import { USER_ROLE_LABELS, canManageSettings, canManageUsers } from '@/lib/roles'; +import { BUSINESS_FIELDS, BUSINESS_FIELD_LABELS } from '@/lib/business-fields'; const auth = useAuthStore(); const loading = ref(false); const saving = ref(false); const error = ref(''); const users = ref([]); +const org = ref(null); +const orgName = ref(''); +const businessField = ref(''); +const orgSaving = ref(false); +const orgOk = ref(''); const email = ref(''); const fullName = ref(''); @@ -29,6 +37,7 @@ const inviteRoles: Array<{ value: Exclude; label: string }> = const isOwner = computed(() => auth.user?.role === 'owner'); const canInvite = computed(() => canManageUsers(auth.user?.role)); +const canEditOrg = computed(() => canManageSettings(auth.user?.role)); function roleOptionsFor(user: OrgUserDto): UserRole[] { if (isOwner.value) return ['owner', 'admin', 'sales', 'support']; @@ -40,7 +49,14 @@ async function load() { loading.value = true; error.value = ''; try { - users.value = (await api.get('/users')).data; + const [usersRes, orgRes] = await Promise.all([ + api.get('/users'), + api.get('/organizations/current'), + ]); + users.value = usersRes.data; + org.value = orgRes.data; + orgName.value = orgRes.data.name; + businessField.value = orgRes.data.businessField ?? ''; } catch (e) { if (axios.isAxiosError(e) && !e.response) { error.value = 'اتصال به API برقرار نشد.'; @@ -52,6 +68,37 @@ async function load() { } } +async function saveOrg() { + if (!canEditOrg.value) return; + if (!businessField.value) { + error.value = 'زمینه فعالیت را انتخاب کنید.'; + return; + } + orgSaving.value = true; + error.value = ''; + orgOk.value = ''; + try { + const payload: UpdateOrganizationRequest = { + name: orgName.value.trim(), + businessField: businessField.value as UpdateOrganizationRequest['businessField'], + }; + const { data } = await api.patch('/organizations/current', payload); + org.value = data; + orgName.value = data.name; + businessField.value = data.businessField ?? ''; + orgOk.value = 'مشخصات سازمان ذخیره شد.'; + } catch (e) { + if (axios.isAxiosError(e)) { + const msg = (e.response?.data as { message?: string | string[] })?.message; + error.value = Array.isArray(msg) ? msg.join('، ') : (msg ?? 'ذخیره سازمان ناموفق بود'); + } else { + error.value = 'ذخیره سازمان ناموفق بود'; + } + } finally { + orgSaving.value = false; + } +} + async function invite() { if (!email.value.trim() || !fullName.value.trim() || password.value.length < 8) { error.value = 'نام، ایمیل و رمز حداقل ۸ کاراکتر لازم است.'; @@ -110,10 +157,33 @@ onMounted(load);

تنظیمات

تیم و نقش‌ها

- کاربران سازمان را اضافه کنید و نقش بدهید. تنظیمات سازمانی فقط برای مالک و مدیر است. + زمینه فعالیت سازمان و کاربران را اینجا مدیریت کنید. تنظیمات سازمانی فقط برای مالک و مدیر است.

+ +

سازمان

+
+ + +
+

{{ orgOk }}

+ + +

{{ error }}

در حال بارگذاری…

@@ -202,6 +272,12 @@ h1 { color: #b42318; } +.ok { + margin: 0; + color: #0f766e; + font-size: 0.9rem; +} + .invite, .row { background: var(--surface); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index dbf1e05..c48c863 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -68,6 +68,14 @@ export interface LoginRequest { password: string; } +export interface RegisterOrganizationRequest { + organizationName: string; + fullName: string; + email: string; + password: string; + businessField: BusinessField; +} + export interface LoginResponse { accessToken: string; user: AuthUserDto; @@ -77,11 +85,70 @@ export interface OrganizationDto { id: string; name: string; slug: string; + businessField: BusinessField | null; baseCurrency: string; enabledCurrencies: string[]; exchangeRates: ExchangeRateDto[]; } +export interface UpdateOrganizationRequest { + name?: string; + businessField?: BusinessField; +} + +/** زمینه فعالیت سازمان — هم‌تراز صنایع دیدار */ +export const BUSINESS_FIELDS = [ + "education", + "beauty", + "real_estate", + "construction", + "advertising", + "insurance", + "consulting", + "startup", + "medical_devices", + "travel", + "contractor", + "trading", + "manufacturing", + "personal", + "tech", + "legal", + "repair", + "retail", + "jewelry", + "other", +] as const; + +export type BusinessField = (typeof BUSINESS_FIELDS)[number]; + +export const BUSINESS_FIELD_LABELS: Record = { + education: "آموزشگاه و مؤسسات آموزشی", + beauty: "کلینیک زیبایی و آرایشگاه", + real_estate: "املاک و مشاورین مسکن", + construction: "صنعت ساختمان", + advertising: "تبلیغات و مشاوره کسب‌وکار", + insurance: "بیمه", + consulting: "شرکت‌های مشاور", + startup: "استارتاپ و کسب‌وکار آنلاین", + medical_devices: "تجهیزات پزشکی", + travel: "گردشگری و مهاجرت", + contractor: "پیمانکاری و پروژه‌ای", + trading: "بازرگانی", + manufacturing: "تولیدی و صنعتی", + personal: "کسب‌وکار شخصی و خانگی", + tech: "نرم‌افزار و سخت‌افزار", + legal: "وکلا و مشاورین حقوقی", + repair: "خدمات و تعمیرات", + retail: "خرده‌فروشی", + jewelry: "طلا و جواهر", + other: "سایر", +}; + +export function isBusinessField(value: string): value is BusinessField { + return (BUSINESS_FIELDS as readonly string[]).includes(value); +} + export interface ExchangeRateDto { currency: string; rateToBase: number;